Compare commits

...

31 Commits

Author SHA1 Message Date
Alex The Bot
1835fbae49 Version v1.68.0 2023-07-20 03:06:12 +00:00
Alex
593489a14c fix(web): cannot use shift-select (#3343) 2023-07-19 21:15:22 -05:00
Alex
9f7bf36786 fix(web): cannot use semicolon on the search bar in asset grid page (#3334)
* fix(web): cannot use semicolon on the search bar

* fix(web): cannot use semicolon on the search bar

* remove console log

* fix: disable hotkey when search is enable

* format

* fix event listener removal
2023-07-19 11:03:23 -05:00
Thomas
f0302670d2 fix(server): add missing extensions and mime types (#3318)
Add extensions and mime types which were accidentally removed in #3197.

Fixes: #3300
2023-07-19 09:27:25 -05:00
Mert
4b8cc7b533 chore: docker compose for prod build (#3333)
* added docker compose for prod build

* updated makefile
2023-07-18 23:41:02 -05:00
Jason Rasmussen
6e953ff5eb fix(server): cancel error (#3332) 2023-07-18 23:40:20 -05:00
Alex
7316ad5a72 chore(web): sort tailwindcss class automatically (#3330) 2023-07-18 13:19:39 -05:00
martin
f28fc8fa5c feat(server,web): hide faces (#3262)
* feat: hide faces

* fix: types

* pr feedback

* fix: svelte checks

* feat: new server endpoint

* refactor: rename person count dto

* fix(server): linter

* fix: remove duplicate button

* docs: add comments

* pr feedback

* fix: get unhidden faces

* fix: do not use PersonCountResponseDto

* fix: transition

* pr feedback

* pr feedback

* fix: remove unused check

* add server tests

* rename persons to people

* feat: add exit button

* pr feedback

* add server tests

* pr feedback

* pr feedback

* fix: show & hide faces

* simplify

* fix: close button

* pr feeback

* pr feeback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-18 13:09:43 -05:00
Alex
02b70e693c feat(web): add better face management UI action (#3328)
* add better face management menu

* context menu

* change name form

* change name

* navigate to merge face

* fix web
2023-07-18 12:36:20 -05:00
bo0tzz
b2e06477f8 chore: Enable logging on typesense container (#3326) 2023-07-18 11:19:16 -05:00
martin
632971a2ac fix: allow edit to empty name (#3322) 2023-07-17 21:20:28 -05:00
Thomas
8045fd3f14 fix(web): remove dependency on rxjs (#3301)
The dependency on rxjs has been removed in favour of iterators as it's clearer
and the nature of the workload is inherently non-reactive. The uncaught error
when the list of files is empty has also been implicitly fixed by this change.

Fixes: #3300
2023-07-17 11:22:29 -05:00
Alex
a2568f711f chore(mobile): remove things sections (#3309)
Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-07-17 11:20:05 -05:00
martin
f9032866e7 feat(web): new shortcuts (#3111)
* feat: shortcuts

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* fix: remove listener on component destroy

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* revert delete shortcut

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* feat: new notifications

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* fix: use handleError

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

---------

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>
2023-07-16 22:16:14 -05:00
Adam Cigánek
e287b18435 fix(mobile): fix forgetting backup albums (#3108) (#3244) 2023-07-17 03:08:58 +00:00
oddlama
c415ee82d1 chore: adjust loglevel of reverse geocoding intializer to LOG (#3303) 2023-07-16 21:57:20 -05:00
KailashGanesh
c8f1a15f21 fix(web): adjusted offset value to match header height (#3302) 2023-07-16 19:23:01 +00:00
Dhrumil Shah
9012cf6946 fix(mobile) - Allow sign out if server is down, or device is offline (#3275)
* WIP: Allow app sign out when server cannot be reached

* WIP: import logging lib

* WIP: move log out up
2023-07-15 20:52:41 -05:00
faupau
7595d01956 feat(web): set asset as profile picture (#3106)
* add profile-image-cropper component

* add dom-to-image library

* add store to update user profile picture when set

* dom-to-image

* remove console.logs, add svelte binding

* fix format, unused vars

* change caching of profile image

* set hash after profile image change

* remove unnecessary store

* remove unecesarry changes

* set types/dom-to-image as devDependency

* remove unecessary type declarations
use handleError

* remove error notification
which is already handled by handleError

* Revert "set types/dom-to-image as devDependency"

This reverts commit ca8b3ed1bb.

* add types do dev dependencies

* use on:close instead of on:close={()=>...}

* add newline

* sort imports

* bind photo-viewer imgElement directly, not working

* remove console.log, fix binding

* make imgElement optional

* fix element as optional prop

* fix type

* check for transparency

* small changes

* fix img.decode

* add bg, remove publicsharedkey

* fix omit publicSharedKey

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-15 20:31:33 -05:00
Sergey Kondrikov
ed3c239b7e fix(web): navigation buttons z-order (#3286)
* Fix navigation styling

* z-index
* refactor transition and hover

* Add NavigationButton and NavigationArea components

* Use group-hover to simplify hover styling

* fix check

* fix check

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-15 20:25:59 -05:00
Alex
c254a04aec feat(server): add endpoint to get supported media types on the server (#3284)
* feat(server): add endpoint to get supported media types on the server

* api generation

* remove xmp format

* change dto

* openapi

* dev
2023-07-15 20:24:46 -05:00
Alex
d5b96c0257 chore(web): Update to Svelte 4 (#3196)
* trying to update to svelte 4

* update dependencies

* remove global transition

* suppress wrning

* chore: install from github

* revert material icon change

* Supress a11y warning

* update

* remove coverage test on web

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-07-15 20:13:04 -05:00
Alex
436a2e9bf3 chore(mobile): share logo platform consistency (#3290) 2023-07-15 20:11:51 -05:00
Brian Di Palma
b34f4345e1 Update _storage-template.md (#3291) 2023-07-15 17:27:27 -05:00
Jason Rasmussen
f55d63fae8 feat(server): storage label claim (#3278)
* feat: storage label claim

* chore: open api
2023-07-15 14:50:29 -05:00
abhi-chakrab
ed594c1987 Update start.sh (#3282)
Adding ability to use docker secrets file for REDIS_PASSWORD
2023-07-15 10:30:52 -05:00
Skkay
ab85dd9fa8 chore(docs): Remove a duplicate word (#3285) 2023-07-15 10:06:34 -05:00
Jason Rasmussen
08c7054845 refactor(server): auth/oauth (#3242)
* refactor(server): auth/oauth

* fix: show server error message on login failure
2023-07-14 23:03:56 -05:00
Harry Tran
9ef41bf1c7 fix(web): update style of rows in user administration table (#3277) 2023-07-14 22:38:16 -05:00
Jason Rasmussen
1064128fde refactor(server): upload config (#3252) 2023-07-14 20:31:42 -05:00
Jason Rasmussen
382341f550 feat(web): show download size (#3270)
* feat(web): show download size

* chore: never over 100%

* chore: use percentage

* fix: unselect assets before download finishes
2023-07-14 20:25:13 -05:00
240 changed files with 24244 additions and 22556 deletions

View File

@@ -136,9 +136,9 @@ jobs:
run: npm run check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test:cov
if: ${{ !cancelled() }}
# - name: Run unit tests & coverage
# run: npm run test:cov
# if: ${{ !cancelled() }}
mobile-unit-tests:
name: Run mobile unit tests

View File

@@ -23,10 +23,10 @@ test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.2
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
*/
'autoLaunch'?: boolean;
}
/**
*
* @export
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'visible': number;
/**
*
* @type {Array<PersonResponseDto>}
* @memberof PeopleResponseDto
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
@@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto
*/
'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
}
/**
*
@@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
}
/**
*
@@ -2085,6 +2122,31 @@ export interface ServerInfoResponseDto {
*/
'diskAvailable': string;
}
/**
*
* @export
* @interface ServerMediaTypesResponseDto
*/
export interface ServerMediaTypesResponseDto {
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'video': Array<string>;
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'image': Array<string>;
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'sidecar': Array<string>;
}
/**
*
* @export
@@ -2596,6 +2658,12 @@ export interface SystemConfigOAuthDto {
* @memberof SystemConfigOAuthDto
*/
'scope': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'storageLabelClaim': string;
/**
*
* @type {string}
@@ -8613,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -8638,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -8883,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -8954,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople(options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise<PeopleResponseDto> {
return localVarFp.getAllPeople(requestParameters.withHidden, options).then((request) => request(axios, basePath));
},
/**
*
@@ -9008,6 +9083,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
};
};
/**
* Request parameters for getAllPeople operation in PersonApi.
* @export
* @interface PersonApiGetAllPeopleRequest
*/
export interface PersonApiGetAllPeopleRequest {
/**
*
* @type {boolean}
* @memberof PersonApiGetAllPeople
*/
readonly withHidden?: boolean
}
/**
* Request parameters for getPerson operation in PersonApi.
* @export
@@ -9101,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getAllPeople(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -9705,6 +9795,35 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSupportedMediaTypes: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/media-types`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -9780,6 +9899,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStats(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSupportedMediaTypes(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerMediaTypesResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSupportedMediaTypes(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@@ -9823,6 +9951,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
getStats(options?: AxiosRequestConfig): AxiosPromise<ServerStatsResponseDto> {
return localVarFp.getStats(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSupportedMediaTypes(options?: AxiosRequestConfig): AxiosPromise<ServerMediaTypesResponseDto> {
return localVarFp.getSupportedMediaTypes(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@@ -9871,6 +10007,16 @@ export class ServerInfoApi extends BaseAPI {
return ServerInfoApiFp(this.configuration).getStats(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public getSupportedMediaTypes(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).getSupportedMediaTypes(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.2
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.2
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.2
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.2
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -0,0 +1,114 @@
version: "3.8"
services:
immich-server:
container_name: immich_server
image: immich-server:latest
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
depends_on:
- redis
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
restart: always
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
depends_on:
- database
- immich-server
- typesense
restart: always
immich-web:
container_name: immich_web
image: immich-web:latest
build:
context: ../web
dockerfile: Dockerfile
env_file:
- .env
restart: always
depends_on:
- immich-server
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always
immich-proxy:
container_name: immich_proxy
image: immich-proxy:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -51,8 +51,6 @@ services:
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
restart: always

View File

@@ -94,7 +94,7 @@ To remove the **Metadata** you can stop Immich and delete the volume.
docker-compose down -v
```
After removing the the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
After removing the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
### Why iOS app shows duplicate photos on the timeline while the web doesn't?

View File

@@ -1,4 +1,4 @@
Immich allows the admin user to set the pattern of how the files are uploaded to the Immich would look like. Both in the directory and the filename level.
Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level.
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text.

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.67.2"
version = "1.68.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" => 90,
"android.injected.version.name" => "1.67.2",
"android.injected.version.code" => 91,
"android.injected.version.name" => "1.68.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')

View File

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

View File

@@ -412,7 +412,11 @@ class GalleryViewerPage extends HookConsumerWidget {
showUnselectedLabels: false,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.ios_share_rounded),
icon: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),

View File

@@ -207,6 +207,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
@@ -235,6 +238,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
}
}
@@ -270,30 +275,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Generate AssetPathEntity from id to add to local state
try {
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Excluded album not found');
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e, stackTrace) {
log.severe("Failed to generate album from id", e, stackTrace);
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -39,7 +41,9 @@ class ControlBottomAppBar extends ConsumerWidget {
return Row(
children: [
ControlBoxButton(
iconData: Icons.ios_share_rounded,
iconData: Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? onShare : null,
),

View File

@@ -33,14 +33,12 @@ class ProfileDrawer extends HookConsumerWidget {
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () async {
bool res = await ref.watch(authenticationProvider.notifier).logout();
await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
}
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
},
);
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
@@ -92,21 +93,29 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
}
Future<bool> logout() async {
Future<void> logout() async {
var log = Logger('AuthenticationNotifier');
try {
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
_apiService.authenticationApi
.logout()
.then((_) => log.info("Logout was successfull for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace),
);
await Future.wait([
_apiService.authenticationApi.logout(),
clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
state = state.copyWith(isAuthenticated: false);
return true;
} catch (e) {
debugPrint("Error logging out $e");
return false;
log.severe("Error logging out $e");
}
}

View File

@@ -75,18 +75,15 @@ class ChangePasswordForm extends HookConsumerWidget {
.changePassword(passwordController.value.text);
if (isSuccess) {
bool res = await ref
await ref
.read(authenticationProvider.notifier)
.logout();
if (res) {
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context)
.replace(const LoginRoute());
}
AutoRouter.of(context).replace(const LoginRoute());
}
}
},

View File

@@ -63,12 +63,3 @@ final getCuratedLocationProvider =
var curatedLocation = await searchService.getCuratedLocation();
return curatedLocation ?? [];
});
final getCuratedObjectProvider =
FutureProvider.autoDispose<List<CuratedObjectsResponseDto>>((ref) async {
final SearchService searchService = ref.watch(searchServiceProvider);
var curatedObject = await searchService.getCuratedObjects();
return curatedObject ?? [];
});

View File

@@ -18,7 +18,8 @@ class PersonService {
Future<List<PersonResponseDto>?> getCuratedPeople() async {
try {
return await _apiService.personApi.getAllPeople();
final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people;
} catch (e) {
debugPrint("Error [getCuratedPeople] ${e.toString()}");
return null;

View File

@@ -1,55 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize.dart';
import 'package:openapi/api.dart';
class CuratedObjectPage extends HookConsumerWidget {
const CuratedObjectPage({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
ref.watch(getCuratedObjectProvider);
return Scaffold(
appBar: AppBar(
title: Text(
'curated_object_page_title',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
).tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedObjects.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(
child: Text('Error: $err'),
),
data: (curatedLocations) => ExploreGrid(
curatedContent: curatedLocations
.map(
(l) => CuratedContent(
label: l.object.capitalize(),
id: l.id,
),
)
.toList(),
),
),
);
}
}

View File

@@ -25,7 +25,6 @@ class SearchPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedObjects = ref.watch(getCuratedObjectProvider);
final curatedPeople = ref.watch(getCuratedPeopleProvider);
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
double imageSize = MediaQuery.of(context).size.width / 3;
@@ -128,40 +127,6 @@ class SearchPage extends HookConsumerWidget {
);
}
buildThings() {
return SizedBox(
height: imageSize,
child: curatedObjects.when(
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => SizedBox(
height: imageSize,
child: Center(child: Text('Error: $err')),
),
data: (objects) => CuratedRow(
content: objects
.map(
(o) => CuratedContent(
id: o.id,
label: o.object,
),
)
.toList(),
imageSize: imageSize,
onTap: (content, index) {
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
);
},
),
),
);
}
return Scaffold(
appBar: ImmichSearchBar(
searchFocusNode: searchFocusNode,
@@ -191,13 +156,6 @@ class SearchPage extends HookConsumerWidget {
top: 0,
),
buildPlaces(),
SearchRowTitle(
title: "search_page_things".tr(),
onViewAllPressed: () => AutoRouter.of(context).push(
const CuratedObjectRoute(),
),
),
buildThings(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),

View File

@@ -30,7 +30,6 @@ import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
import 'package:immich_mobile/modules/search/views/person_result_page.dart';
import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
@@ -87,7 +86,6 @@ part 'router.gr.dart';
AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),

View File

@@ -111,12 +111,6 @@ class _$AppRouter extends RootStackRouter {
child: const CuratedLocationPage(),
);
},
CuratedObjectRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const CuratedObjectPage(),
);
},
CreateAlbumRoute.name: (routeData) {
final args = routeData.argsAs<CreateAlbumRouteArgs>();
return MaterialPageX<dynamic>(
@@ -441,14 +435,6 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
CuratedObjectRoute.name,
path: '/curated-object-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
CreateAlbumRoute.name,
path: '/create-album-page',
@@ -507,7 +493,7 @@ class _$AppRouter extends RootStackRouter {
),
RouteConfig(
AlbumViewerRoute.name,
path: '/',
path: '/album-viewer-page',
guards: [
authGuard,
duplicateGuard,
@@ -839,18 +825,6 @@ class CuratedLocationRoute extends PageRouteInfo<void> {
static const String name = 'CuratedLocationRoute';
}
/// generated route for
/// [CuratedObjectPage]
class CuratedObjectRoute extends PageRouteInfo<void> {
const CuratedObjectRoute()
: super(
CuratedObjectRoute.name,
path: '/curated-object-page',
);
static const String name = 'CuratedObjectRoute';
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
@@ -1020,7 +994,7 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
required int albumId,
}) : super(
AlbumViewerRoute.name,
path: '/',
path: '/album-viewer-page',
args: AlbumViewerRouteArgs(
key: key,
albumId: albumId,

View File

@@ -33,7 +33,6 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'SearchRoute') {
// Refresh Location State
ref.invalidate(getCuratedLocationProvider);
ref.invalidate(getCuratedObjectProvider);
ref.invalidate(getCuratedPeopleProvider);
}

View File

@@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
@@ -88,6 +89,7 @@ doc/SearchFacetResponseDto.md
doc/SearchResponseDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
doc/ServerMediaTypesResponseDto.md
doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md
@@ -207,6 +209,7 @@ lib/model/merge_person_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/people_response_dto.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
@@ -221,6 +224,7 @@ lib/model/search_facet_count_response_dto.dart
lib/model/search_facet_response_dto.dart
lib/model/search_response_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_media_types_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
@@ -320,6 +324,7 @@ test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/people_response_dto_test.dart
test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart
@@ -337,6 +342,7 @@ test/search_facet_response_dto_test.dart
test/search_response_dto_test.dart
test/server_info_api_test.dart
test/server_info_response_dto_test.dart
test/server_media_types_response_dto_test.dart
test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart
test/server_version_reponse_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.67.2
- API version: 1.68.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -141,6 +141,7 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
*ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets |
*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link |
@@ -237,6 +238,7 @@ Class | Method | HTTP request | Description
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
@@ -251,6 +253,7 @@ Class | Method | HTTP request | Description
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
- [SearchResponseDto](doc//SearchResponseDto.md)
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
- [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
- [ServerPingResponse](doc//ServerPingResponse.md)
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)

17
mobile/openapi/doc/PeopleResponseDto.md generated Normal file
View File

@@ -0,0 +1,17 @@
# openapi.model.PeopleResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**total** | **num** | |
**visible** | **num** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -18,7 +18,7 @@ Method | HTTP request | Description
# **getAllPeople**
> List<PersonResponseDto> getAllPeople()
> PeopleResponseDto getAllPeople(withHidden)
@@ -41,9 +41,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
final withHidden = true; // bool |
try {
final result = api_instance.getAllPeople();
final result = api_instance.getAllPeople(withHidden);
print(result);
} catch (e) {
print('Exception when calling PersonApi->getAllPeople: $e\n');
@@ -51,11 +52,14 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**withHidden** | **bool**| | [optional] [default to false]
### Return type
[**List<PersonResponseDto>**](PersonResponseDto.md)
[**PeopleResponseDto**](PeopleResponseDto.md)
### Authorization

View File

@@ -11,6 +11,7 @@ Name | Type | Description | Notes
**id** | **String** | |
**name** | **String** | |
**thumbnailPath** | **String** | |
**isHidden** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **String** | Person name. | [optional]
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
**isHidden** | **bool** | Person visibility | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -12,6 +12,7 @@ Method | HTTP request | Description
[**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info |
[**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
[**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats |
[**getSupportedMediaTypes**](ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
[**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -154,6 +155,43 @@ 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)
# **getSupportedMediaTypes**
> ServerMediaTypesResponseDto getSupportedMediaTypes()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = ServerInfoApi();
try {
final result = api_instance.getSupportedMediaTypes();
print(result);
} catch (e) {
print('Exception when calling ServerInfoApi->getSupportedMediaTypes: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**ServerMediaTypesResponseDto**](ServerMediaTypesResponseDto.md)
### Authorization
No authorization required
### 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)
# **pingServer**
> ServerPingResponse pingServer()

View File

@@ -0,0 +1,17 @@
# openapi.model.ServerMediaTypesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**video** | **List<String>** | | [default to const []]
**image** | **List<String>** | | [default to const []]
**sidecar** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
**clientId** | **String** | |
**clientSecret** | **String** | |
**scope** | **String** | |
**storageLabelClaim** | **String** | |
**buttonText** | **String** | |
**autoRegister** | **bool** | |
**autoLaunch** | **bool** | |

View File

@@ -104,6 +104,7 @@ part 'model/merge_person_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
part 'model/o_auth_config_response_dto.dart';
part 'model/people_response_dto.dart';
part 'model/person_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';
@@ -118,6 +119,7 @@ part 'model/search_facet_count_response_dto.dart';
part 'model/search_facet_response_dto.dart';
part 'model/search_response_dto.dart';
part 'model/server_info_response_dto.dart';
part 'model/server_media_types_response_dto.dart';
part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart';
part 'model/server_version_reponse_dto.dart';

View File

@@ -17,7 +17,10 @@ class PersonApi {
final ApiClient apiClient;
/// Performs an HTTP 'GET /person' operation and returns the [Response].
Future<Response> getAllPeopleWithHttpInfo() async {
/// Parameters:
///
/// * [bool] withHidden:
Future<Response> getAllPeopleWithHttpInfo({ bool? withHidden, }) async {
// ignore: prefer_const_declarations
final path = r'/person';
@@ -28,6 +31,10 @@ class PersonApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (withHidden != null) {
queryParams.addAll(_queryParams('', 'withHidden', withHidden));
}
const contentTypes = <String>[];
@@ -42,8 +49,11 @@ class PersonApi {
);
}
Future<List<PersonResponseDto>?> getAllPeople() async {
final response = await getAllPeopleWithHttpInfo();
/// Parameters:
///
/// * [bool] withHidden:
Future<PeopleResponseDto?> getAllPeople({ bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -51,11 +61,8 @@ class PersonApi {
// 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<PersonResponseDto>') as List)
.cast<PersonResponseDto>()
.toList();
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PeopleResponseDto',) as PeopleResponseDto;
}
return null;
}

View File

@@ -139,6 +139,47 @@ class ServerInfoApi {
return null;
}
/// Performs an HTTP 'GET /server-info/media-types' operation and returns the [Response].
Future<Response> getSupportedMediaTypesWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server-info/media-types';
// 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<ServerMediaTypesResponseDto?> getSupportedMediaTypes() async {
final response = await getSupportedMediaTypesWithHttpInfo();
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /server-info/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -303,6 +303,8 @@ class ApiClient {
return OAuthConfigDto.fromJson(value);
case 'OAuthConfigResponseDto':
return OAuthConfigResponseDto.fromJson(value);
case 'PeopleResponseDto':
return PeopleResponseDto.fromJson(value);
case 'PersonResponseDto':
return PersonResponseDto.fromJson(value);
case 'PersonUpdateDto':
@@ -331,6 +333,8 @@ class ApiClient {
return SearchResponseDto.fromJson(value);
case 'ServerInfoResponseDto':
return ServerInfoResponseDto.fromJson(value);
case 'ServerMediaTypesResponseDto':
return ServerMediaTypesResponseDto.fromJson(value);
case 'ServerPingResponse':
return ServerPingResponse.fromJson(value);
case 'ServerStatsResponseDto':

View File

@@ -0,0 +1,114 @@
//
// 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 PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({
required this.total,
required this.visible,
this.people = const [],
});
num total;
num visible;
List<PersonResponseDto> people;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
other.total == total &&
other.visible == visible &&
other.people == people;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(total.hashCode) +
(visible.hashCode) +
(people.hashCode);
@override
String toString() => 'PeopleResponseDto[total=$total, visible=$visible, people=$people]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'total'] = this.total;
json[r'visible'] = this.visible;
json[r'people'] = this.people;
return json;
}
/// Returns a new [PeopleResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PeopleResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PeopleResponseDto(
total: num.parse('${json[r'total']}'),
visible: num.parse('${json[r'visible']}'),
people: PersonResponseDto.listFromJson(json[r'people']),
);
}
return null;
}
static List<PeopleResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PeopleResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PeopleResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PeopleResponseDto> mapFromJson(dynamic json) {
final map = <String, PeopleResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PeopleResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PeopleResponseDto-objects as value to a dart map
static Map<String, List<PeopleResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PeopleResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PeopleResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'total',
'visible',
'people',
};
}

View File

@@ -16,6 +16,7 @@ class PersonResponseDto {
required this.id,
required this.name,
required this.thumbnailPath,
required this.isHidden,
});
String id;
@@ -24,27 +25,32 @@ class PersonResponseDto {
String thumbnailPath;
bool isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.id == id &&
other.name == name &&
other.thumbnailPath == thumbnailPath;
other.thumbnailPath == thumbnailPath &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode);
(thumbnailPath.hashCode) +
(isHidden.hashCode);
@override
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath]';
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
json[r'isHidden'] = this.isHidden;
return json;
}
@@ -59,6 +65,7 @@ class PersonResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
);
}
return null;
@@ -109,6 +116,7 @@ class PersonResponseDto {
'id',
'name',
'thumbnailPath',
'isHidden',
};
}

View File

@@ -15,6 +15,7 @@ class PersonUpdateDto {
PersonUpdateDto({
this.name,
this.featureFaceAssetId,
this.isHidden,
});
/// Person name.
@@ -35,19 +36,30 @@ class PersonUpdateDto {
///
String? featureFaceAssetId;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.name == name &&
other.featureFaceAssetId == featureFaceAssetId;
other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(name == null ? 0 : name!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode);
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode);
@override
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId]';
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -61,6 +73,11 @@ class PersonUpdateDto {
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
// json[r'isHidden'] = null;
}
return json;
}
@@ -74,6 +91,7 @@ class PersonUpdateDto {
return PersonUpdateDto(
name: mapValueOfType<String>(json, r'name'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
);
}
return null;

View File

@@ -0,0 +1,120 @@
//
// 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 ServerMediaTypesResponseDto {
/// Returns a new [ServerMediaTypesResponseDto] instance.
ServerMediaTypesResponseDto({
this.video = const [],
this.image = const [],
this.sidecar = const [],
});
List<String> video;
List<String> image;
List<String> sidecar;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerMediaTypesResponseDto &&
other.video == video &&
other.image == image &&
other.sidecar == sidecar;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(video.hashCode) +
(image.hashCode) +
(sidecar.hashCode);
@override
String toString() => 'ServerMediaTypesResponseDto[video=$video, image=$image, sidecar=$sidecar]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'video'] = this.video;
json[r'image'] = this.image;
json[r'sidecar'] = this.sidecar;
return json;
}
/// Returns a new [ServerMediaTypesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerMediaTypesResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ServerMediaTypesResponseDto(
video: json[r'video'] is Iterable
? (json[r'video'] as Iterable).cast<String>().toList(growable: false)
: const [],
image: json[r'image'] is Iterable
? (json[r'image'] as Iterable).cast<String>().toList(growable: false)
: const [],
sidecar: json[r'sidecar'] is Iterable
? (json[r'sidecar'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<ServerMediaTypesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerMediaTypesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerMediaTypesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerMediaTypesResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerMediaTypesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerMediaTypesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerMediaTypesResponseDto-objects as value to a dart map
static Map<String, List<ServerMediaTypesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerMediaTypesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ServerMediaTypesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'video',
'image',
'sidecar',
};
}

View File

@@ -18,6 +18,7 @@ class SystemConfigOAuthDto {
required this.clientId,
required this.clientSecret,
required this.scope,
required this.storageLabelClaim,
required this.buttonText,
required this.autoRegister,
required this.autoLaunch,
@@ -35,6 +36,8 @@ class SystemConfigOAuthDto {
String scope;
String storageLabelClaim;
String buttonText;
bool autoRegister;
@@ -52,6 +55,7 @@ class SystemConfigOAuthDto {
other.clientId == clientId &&
other.clientSecret == clientSecret &&
other.scope == scope &&
other.storageLabelClaim == storageLabelClaim &&
other.buttonText == buttonText &&
other.autoRegister == autoRegister &&
other.autoLaunch == autoLaunch &&
@@ -66,6 +70,7 @@ class SystemConfigOAuthDto {
(clientId.hashCode) +
(clientSecret.hashCode) +
(scope.hashCode) +
(storageLabelClaim.hashCode) +
(buttonText.hashCode) +
(autoRegister.hashCode) +
(autoLaunch.hashCode) +
@@ -73,7 +78,7 @@ class SystemConfigOAuthDto {
(mobileRedirectUri.hashCode);
@override
String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister, autoLaunch=$autoLaunch, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]';
String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, storageLabelClaim=$storageLabelClaim, buttonText=$buttonText, autoRegister=$autoRegister, autoLaunch=$autoLaunch, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -82,6 +87,7 @@ class SystemConfigOAuthDto {
json[r'clientId'] = this.clientId;
json[r'clientSecret'] = this.clientSecret;
json[r'scope'] = this.scope;
json[r'storageLabelClaim'] = this.storageLabelClaim;
json[r'buttonText'] = this.buttonText;
json[r'autoRegister'] = this.autoRegister;
json[r'autoLaunch'] = this.autoLaunch;
@@ -103,6 +109,7 @@ class SystemConfigOAuthDto {
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
scope: mapValueOfType<String>(json, r'scope')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
buttonText: mapValueOfType<String>(json, r'buttonText')!,
autoRegister: mapValueOfType<bool>(json, r'autoRegister')!,
autoLaunch: mapValueOfType<bool>(json, r'autoLaunch')!,
@@ -160,6 +167,7 @@ class SystemConfigOAuthDto {
'clientId',
'clientSecret',
'scope',
'storageLabelClaim',
'buttonText',
'autoRegister',
'autoLaunch',

View File

@@ -0,0 +1,37 @@
//
// 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 PeopleResponseDto
void main() {
// final instance = PeopleResponseDto();
group('test PeopleResponseDto', () {
// num total
test('to test the property `total`', () async {
// TODO
});
// num visible
test('to test the property `visible`', () async {
// TODO
});
// List<PersonResponseDto> people (default value: const [])
test('to test the property `people`', () async {
// TODO
});
});
}

View File

@@ -17,7 +17,7 @@ void main() {
// final instance = PersonApi();
group('tests for PersonApi', () {
//Future<List<PersonResponseDto>> getAllPeople() async
//Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
test('test getAllPeople', () async {
// TODO
});

View File

@@ -31,6 +31,11 @@ void main() {
// TODO
});
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

View File

@@ -28,6 +28,12 @@ void main() {
// TODO
});
// Person visibility
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

View File

@@ -32,6 +32,11 @@ void main() {
// TODO
});
//Future<ServerMediaTypesResponseDto> getSupportedMediaTypes() async
test('test getSupportedMediaTypes', () async {
// TODO
});
//Future<ServerPingResponse> pingServer() async
test('test pingServer', () async {
// TODO

View File

@@ -0,0 +1,37 @@
//
// 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 ServerMediaTypesResponseDto
void main() {
// final instance = ServerMediaTypesResponseDto();
group('test ServerMediaTypesResponseDto', () {
// List<String> video (default value: const [])
test('to test the property `video`', () async {
// TODO
});
// List<String> image (default value: const [])
test('to test the property `image`', () async {
// TODO
});
// List<String> sidecar (default value: const [])
test('to test the property `sidecar`', () async {
// TODO
});
});
}

View File

@@ -41,6 +41,11 @@ void main() {
// TODO
});
// String storageLabelClaim
test('to test the property `storageLabelClaim`', () async {
// TODO
});
// String buttonText
test('to test the property `buttonText`', () 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.67.2+90
version: 1.68.0+91
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -2509,17 +2509,24 @@
"/person": {
"get": {
"operationId": "getAllPeople",
"parameters": [],
"parameters": [
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
"$ref": "#/components/schemas/PeopleResponseDto"
}
}
}
@@ -3040,6 +3047,27 @@
]
}
},
"/server-info/media-types": {
"get": {
"operationId": "getSupportedMediaTypes",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerMediaTypesResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
@@ -4368,7 +4396,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.67.2",
"version": "1.68.0",
"contact": {}
},
"tags": [],
@@ -5856,6 +5884,28 @@
"passwordLoginEnabled"
]
},
"PeopleResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"visible": {
"type": "number"
},
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
}
},
"required": [
"total",
"visible",
"people"
]
},
"PersonResponseDto": {
"type": "object",
"properties": {
@@ -5867,12 +5917,16 @@
},
"thumbnailPath": {
"type": "string"
},
"isHidden": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"thumbnailPath"
"thumbnailPath",
"isHidden"
]
},
"PersonUpdateDto": {
@@ -5885,6 +5939,10 @@
"featureFaceAssetId": {
"type": "string",
"description": "Asset is used to get the feature face thumbnail."
},
"isHidden": {
"type": "boolean",
"description": "Person visibility"
}
}
},
@@ -6118,6 +6176,34 @@
"diskAvailable"
]
},
"ServerMediaTypesResponseDto": {
"type": "object",
"properties": {
"video": {
"type": "array",
"items": {
"type": "string"
}
},
"image": {
"type": "array",
"items": {
"type": "string"
}
},
"sidecar": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"video",
"image",
"sidecar"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
@@ -6503,6 +6589,9 @@
"scope": {
"type": "string"
},
"storageLabelClaim": {
"type": "string"
},
"buttonText": {
"type": "string"
},
@@ -6525,6 +6614,7 @@
"clientId",
"clientSecret",
"scope",
"storageLabelClaim",
"buttonText",
"autoRegister",
"autoLaunch",

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.67.2",
"version": "1.68.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.67.2",
"version": "1.68.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",

View File

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

View File

@@ -1,18 +1,20 @@
import { AssetType } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newAssetRepositoryMock,
newCryptoRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto';
import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService } from './asset.service';
import { AssetService, UploadFieldName } from './asset.service';
import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { mapAsset } from './response-dto';
@@ -39,10 +41,110 @@ const statResponse: AssetStatsResponseDto = {
total: 33,
};
const uploadFile = {
nullAuth: {
authUser: null,
fieldName: UploadFieldName.ASSET_DATA,
file: {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
return {
authUser: authStub.admin,
fieldName,
file: {
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
},
};
},
};
const validImages = [
'.3fr',
'.ari',
'.arw',
'.avif',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.gif',
'.heic',
'.heif',
'.iiq',
'.jpeg',
'.jpg',
'.jxl',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.orf',
'.ori',
'.pef',
'.png',
'.raf',
'.raw',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.tiff',
'.webp',
'.x3f',
];
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
const uploadTests = [
{
label: 'asset images',
fieldName: UploadFieldName.ASSET_DATA,
valid: validImages,
invalid: ['.html', '.xml'],
},
{
label: 'asset videos',
fieldName: UploadFieldName.ASSET_DATA,
valid: validVideos,
invalid: ['.html', '.xml'],
},
{
label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
valid: validVideos,
invalid: ['.html', '.jpeg', '.jpg', '.xml'],
},
{
label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA,
valid: ['.xmp'],
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
},
{
label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA,
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
},
];
describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => {
@@ -52,8 +154,93 @@ describe(AssetService.name, () => {
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, storageMock);
sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock);
});
describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, valid, invalid } of uploadTests) {
describe(fieldName, () => {
for (const filetype of valid) {
it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
}
it('should be sorted (valid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(valid).toEqual([...valid].sort());
});
it('should be sorted (invalid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(invalid).toEqual([...invalid].sort());
});
});
}
});
describe('getUploadFilename', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should be the original extension for asset upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
it('should be the mov extension for live photo upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
'random-uuid.mov',
);
});
it('should be the xmp extension for sidecar upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
'random-uuid.xmp',
);
});
it('should be the original extension for profile upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
});
describe('getUploadFolder', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id');
});
});
describe('getMapMarkers', () => {

View File

@@ -1,12 +1,14 @@
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Inject } from '@nestjs/common';
import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { DateTime } from 'luxon';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository';
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
@@ -21,6 +23,12 @@ export enum UploadFieldName {
PROFILE_DATA = 'file',
}
export interface UploadRequest {
authUser: AuthUserDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface UploadFile {
checksum: Buffer;
originalPath: string;
@@ -28,16 +36,82 @@ export interface UploadFile {
}
export class AssetService {
private logger = new Logger(AssetService.name);
private access: AccessCore;
private storageCore = new StorageCore();
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.access = new AccessCore(accessRepository);
}
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
case UploadFieldName.LIVE_PHOTO_DATA:
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
case UploadFieldName.SIDECAR_DATA:
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
case UploadFieldName.PROFILE_DATA:
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(authUser);
const originalExt = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
};
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
}
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);
return folder;
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}

View File

@@ -54,7 +54,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
};
}

View File

@@ -1,3 +1,5 @@
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_AUTH_TYPE_COOKIE = 'immich_auth_type';
export const IMMICH_API_KEY_NAME = 'api_key';

View File

@@ -1,62 +0,0 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
import { LoginResponseDto, mapLoginResponse } from './response-dto';
export interface LoginDetails {
isSecure: boolean;
clientIp: string;
deviceType: string;
deviceOS: string;
}
export class AuthCore {
private userTokenCore: UserTokenCore;
constructor(
private cryptoRepository: ICryptoRepository,
configRepository: ISystemConfigRepository,
userTokenRepository: IUserTokenRepository,
private config: SystemConfig,
) {
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
const configCore = new SystemConfigCore(configRepository);
configCore.config$.subscribe((config) => (this.config = config));
}
isPasswordLoginEnabled() {
return this.config.passwordLogin.enabled;
}
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
let accessTokenCookie = '';
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;`;
} 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;`;
}
return [accessTokenCookie, authTypeCookie];
}
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const accessToken = await this.userTokenCore.create(user, loginDetails);
const response = mapLoginResponse(user, accessToken);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}
validatePassword(inputPassword: string, user: UserEntity): boolean {
if (!user || !user.password) {
return false;
}
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
}

View File

@@ -1,4 +1,4 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { UserEntity } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import {
authStub,
@@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISharedLinkRepository } from '../shared-link';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';
import { AuthType } from './auth.constant';
import { AuthService } from './auth.service';
import { AuthUserDto, SignUpDto } from './dto';
import { IUserTokenRepository } from './user-token.repository';
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@@ -55,7 +55,6 @@ describe('AuthService', () => {
let shareMock: jest.Mocked<ISharedLinkRepository>;
let keyMock: jest.Mocked<IKeyRepository>;
let callbackMock: jest.Mock;
let create: (config: SystemConfig) => AuthService;
afterEach(() => {
jest.resetModules();
@@ -87,9 +86,7 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
sut = create(systemConfigStub.enabled);
sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock);
});
it('should be defined', () => {
@@ -98,8 +95,7 @@ describe('AuthService', () => {
describe('login', () => {
it('should throw an error if password login is disabled', async () => {
sut = create(systemConfigStub.disabled);
configMock.load.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
});
@@ -191,8 +187,8 @@ describe('AuthService', () => {
describe('logout', () => {
it('should return the end session endpoint', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
const authUser = { id: '123' } as AuthUserDto;
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
@@ -385,4 +381,132 @@ describe('AuthService', () => {
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, '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');
});
it('should work if called without query params', () => {
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
});
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
configMock.load.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: false,
});
});
it('should generate the config', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});
describe('callback', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userMock.update.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
});
it('should allow auto registering by default', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
userMock.create.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userMock.create).toHaveBeenCalledTimes(1);
});
it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
});
describe('link', () => {
it('should link an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.unlink(authStub.user1);
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
});
});
});

View File

@@ -1,4 +1,4 @@
import { SystemConfig } from '@app/infra/entities';
import { SystemConfig, UserEntity } from '@app/infra/entities';
import {
BadRequestException,
Inject,
@@ -9,99 +9,112 @@ import {
} from '@nestjs/common';
import cookieParser from 'cookie';
import { IncomingHttpHeaders } from 'http';
import { DateTime } from 'luxon';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { IKeyRepository } from '../api-key';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { OAuthCore } from '../oauth/oauth.core';
import { ISharedLinkRepository } from '../shared-link';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore } from '../user';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
import { AuthCore, LoginDetails } from './auth.core';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { IUserRepository, UserCore, UserResponseDto } from '../user';
import {
AuthType,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_AUTH_TYPE_COOKIE,
LOGIN_URL,
MOBILE_REDIRECT,
} from './auth.constant';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto } from './dto';
import {
AdminSignupResponseDto,
AuthDeviceResponseDto,
LoginResponseDto,
LogoutResponseDto,
mapAdminSignupResponse,
mapLoginResponse,
mapUserToken,
OAuthConfigResponseDto,
} from './response-dto';
import { IUserTokenRepository } from './user-token.repository';
export interface LoginDetails {
isSecure: boolean;
clientIp: string;
deviceType: string;
deviceOS: string;
}
interface LoginResponse {
response: LoginResponseDto;
cookie: string[];
}
interface OAuthProfile extends UserinfoResponse {
email: string;
}
@Injectable()
export class AuthService {
private userTokenCore: UserTokenCore;
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private configCore: SystemConfigCore;
private logger = new Logger(AuthService.name);
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
@Inject(INITIAL_SYSTEM_CONFIG)
initialConfig: SystemConfig,
) {
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
this.configCore = new SystemConfigCore(configRepository);
this.userCore = new UserCore(userRepository, cryptoRepository);
custom.setHttpOptionsDefaults({ timeout: 30000 });
}
public async login(
loginCredential: LoginCredentialDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
if (!this.authCore.isPasswordLoginEnabled()) {
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
const config = await this.configCore.getConfig();
if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userCore.getByEmail(loginCredential.email, true);
let user = await this.userCore.getByEmail(dto.email, true);
if (user) {
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
const isAuthenticated = this.validatePassword(dto.password, user);
if (!isAuthenticated) {
user = null;
}
}
if (!user) {
this.logger.warn(
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
);
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
throw new BadRequestException('Incorrect email or password');
}
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
return this.createLoginResponse(user, AuthType.PASSWORD, details);
}
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
if (authUser.accessTokenId) {
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
}
if (authType === AuthType.OAUTH) {
const url = await this.oauthCore.getLogoutEndpoint();
if (url) {
return { successful: true, redirectUri: url };
}
}
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
return {
successful: true,
redirectUri: await this.getLogoutEndpoint(authType),
};
}
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
const { password, newPassword } = dto;
const user = await this.userCore.getByEmail(authUser.email, true);
if (!user) {
throw new UnauthorizedException();
}
const valid = this.authCore.validatePassword(password, user);
const valid = this.validatePassword(password, user);
if (!valid) {
throw new BadRequestException('Wrong password');
}
@@ -109,7 +122,7 @@ export class AuthService {
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
}
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userCore.getAdmin();
if (adminUser) {
@@ -133,7 +146,7 @@ export class AuthService {
}
}
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
const userToken = (headers['x-immich-user-token'] ||
params.userToken ||
@@ -146,7 +159,7 @@ export class AuthService {
}
if (userToken) {
return this.userTokenCore.validate(userToken);
return this.validateUserToken(userToken);
}
if (apiKey) {
@@ -157,24 +170,163 @@ export class AuthService {
}
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenCore.getAll(authUser.id);
const userTokens = await this.userTokenRepository.getAll(authUser.id);
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
}
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
await this.userTokenCore.delete(authUser.id, deviceId);
await this.userTokenRepository.delete(authUser.id, deviceId);
}
async logoutDevices(authUser: AuthUserDto): Promise<void> {
const devices = await this.userTokenCore.getAll(authUser.id);
const devices = await this.userTokenRepository.getAll(authUser.id);
for (const device of devices) {
if (device.id === authUser.accessTokenId) {
continue;
}
await this.userTokenCore.delete(authUser.id, device.id);
await this.userTokenRepository.delete(authUser.id, device.id);
}
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const config = await this.configCore.getConfig();
const response = {
enabled: config.oauth.enabled,
passwordLoginEnabled: config.passwordLogin.enabled,
};
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = config.oauth;
const url = (await this.getOAuthClient(config)).authorizationUrl({
redirect_uri: this.normalize(config, dto.redirectUri),
scope,
state: generators.state(),
});
return { ...response, buttonText, url, autoLaunch };
}
async callback(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const config = await this.configCore.getConfig();
const profile = await this.getOAuthProfile(config, dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!config.oauth.autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
let storageLabel: string | null = profile[config.oauth.storageLabelClaim as keyof OAuthProfile] as string;
if (typeof storageLabel !== 'string') {
storageLabel = null;
}
user = await this.userCore.createUser({
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
storageLabel,
});
}
return this.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const config = await this.configCore.getConfig();
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
if (authType !== AuthType.OAUTH) {
return LOGIN_URL;
}
const config = await this.configCore.getConfig();
if (!config.oauth.enabled) {
return LOGIN_URL;
}
const client = await this.getOAuthClient(config);
return client.issuer.metadata.end_session_endpoint || LOGIN_URL;
}
private async getOAuthProfile(config: SystemConfig, url: string): Promise<OAuthProfile> {
const redirectUri = this.normalize(config, url.split('?')[0]);
const client = await this.getOAuthClient(config);
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return client.userinfo<OAuthProfile>(tokens.access_token || '');
}
private async getOAuthClient(config: SystemConfig) {
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(config: SystemConfig, redirectUri: string) {
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
const [type, token] = (headers.authorization || '').split(' ');
if (type.toLowerCase() === 'bearer') {
@@ -232,4 +384,68 @@ export class AuthService {
throw new UnauthorizedException('Invalid API key');
}
private validatePassword(inputPassword: string, user: UserEntity): boolean {
if (!user || !user.password) {
return false;
}
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
private async validateUserToken(tokenValue: string): Promise<AuthUserDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let token = await this.userTokenRepository.getByToken(hashedToken);
if (token?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(token.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() });
}
return {
...token.user,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowExif: true,
accessTokenId: token.id,
};
}
throw new UnauthorizedException('Invalid user token');
}
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, '');
const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.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 = '';
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;`;
} 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;`;
}
return [accessTokenCookie, authTypeCookie];
}
}

View File

@@ -1,4 +1,6 @@
export * from './auth-user.dto';
export * from './change-password.dto';
export * from './login-credential.dto';
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';
export * from './sign-up.dto';

View File

@@ -1,5 +1,5 @@
export * from './auth.constant';
export * from './auth.core';
export * from './auth.service';
export * from './dto';
export * from './response-dto';
export * from './user-token.repository';

View File

@@ -2,4 +2,5 @@ export * from './admin-signup-response.dto';
export * from './auth-device-response.dto';
export * from './login-response.dto';
export * from './logout-response.dto';
export * from './oauth-config-response.dto';
export * from './validate-asset-token-response.dto';

View File

@@ -0,0 +1,191 @@
import { mimeTypes } from '@app/domain';
describe('mimeTypes', () => {
for (const { mimetype, extension } of [
// Please ensure this list is sorted.
{ mimetype: 'image/3fr', extension: '.3fr' },
{ mimetype: 'image/ari', extension: '.ari' },
{ mimetype: 'image/arw', extension: '.arw' },
{ mimetype: 'image/avif', extension: '.avif' },
{ mimetype: 'image/cap', extension: '.cap' },
{ mimetype: 'image/cin', extension: '.cin' },
{ mimetype: 'image/cr2', extension: '.cr2' },
{ mimetype: 'image/cr3', extension: '.cr3' },
{ mimetype: 'image/crw', extension: '.crw' },
{ mimetype: 'image/dcr', extension: '.dcr' },
{ mimetype: 'image/dng', extension: '.dng' },
{ mimetype: 'image/erf', extension: '.erf' },
{ mimetype: 'image/fff', extension: '.fff' },
{ mimetype: 'image/gif', extension: '.gif' },
{ mimetype: 'image/heic', extension: '.heic' },
{ mimetype: 'image/heif', extension: '.heif' },
{ mimetype: 'image/iiq', extension: '.iiq' },
{ mimetype: 'image/jpeg', extension: '.jpeg' },
{ mimetype: 'image/jpeg', extension: '.jpg' },
{ mimetype: 'image/jxl', extension: '.jxl' },
{ mimetype: 'image/k25', extension: '.k25' },
{ mimetype: 'image/kdc', extension: '.kdc' },
{ mimetype: 'image/mrw', extension: '.mrw' },
{ mimetype: 'image/nef', extension: '.nef' },
{ mimetype: 'image/orf', extension: '.orf' },
{ mimetype: 'image/ori', extension: '.ori' },
{ mimetype: 'image/pef', extension: '.pef' },
{ mimetype: 'image/png', extension: '.png' },
{ mimetype: 'image/raf', extension: '.raf' },
{ mimetype: 'image/raw', extension: '.raw' },
{ mimetype: 'image/rwl', extension: '.rwl' },
{ mimetype: 'image/sr2', extension: '.sr2' },
{ mimetype: 'image/srf', extension: '.srf' },
{ mimetype: 'image/srw', extension: '.srw' },
{ mimetype: 'image/tiff', extension: '.tiff' },
{ mimetype: 'image/webp', extension: '.webp' },
{ mimetype: 'image/x-adobe-dng', extension: '.dng' },
{ mimetype: 'image/x-arriflex-ari', extension: '.ari' },
{ mimetype: 'image/x-canon-cr2', extension: '.cr2' },
{ mimetype: 'image/x-canon-cr3', extension: '.cr3' },
{ mimetype: 'image/x-canon-crw', extension: '.crw' },
{ mimetype: 'image/x-epson-erf', extension: '.erf' },
{ mimetype: 'image/x-fuji-raf', extension: '.raf' },
{ mimetype: 'image/x-hasselblad-3fr', extension: '.3fr' },
{ mimetype: 'image/x-hasselblad-fff', extension: '.fff' },
{ mimetype: 'image/x-kodak-dcr', extension: '.dcr' },
{ mimetype: 'image/x-kodak-k25', extension: '.k25' },
{ mimetype: 'image/x-kodak-kdc', extension: '.kdc' },
{ mimetype: 'image/x-leica-rwl', extension: '.rwl' },
{ mimetype: 'image/x-minolta-mrw', extension: '.mrw' },
{ mimetype: 'image/x-nikon-nef', extension: '.nef' },
{ mimetype: 'image/x-olympus-orf', extension: '.orf' },
{ mimetype: 'image/x-olympus-ori', extension: '.ori' },
{ mimetype: 'image/x-panasonic-raw', extension: '.raw' },
{ mimetype: 'image/x-pentax-pef', extension: '.pef' },
{ mimetype: 'image/x-phantom-cin', extension: '.cin' },
{ mimetype: 'image/x-phaseone-cap', extension: '.cap' },
{ mimetype: 'image/x-phaseone-iiq', extension: '.iiq' },
{ mimetype: 'image/x-samsung-srw', extension: '.srw' },
{ mimetype: 'image/x-sigma-x3f', extension: '.x3f' },
{ mimetype: 'image/x-sony-arw', extension: '.arw' },
{ mimetype: 'image/x-sony-sr2', extension: '.sr2' },
{ mimetype: 'image/x-sony-srf', extension: '.srf' },
{ mimetype: 'image/x3f', extension: '.x3f' },
{ mimetype: 'video/3gpp', extension: '.3gp' },
{ mimetype: 'video/avi', extension: '.avi' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpg' },
{ mimetype: 'video/msvideo', extension: '.avi' },
{ mimetype: 'video/quicktime', extension: '.mov' },
{ mimetype: 'video/vnd.avi', extension: '.avi' },
{ mimetype: 'video/webm', extension: '.webm' },
{ mimetype: 'video/x-flv', extension: '.flv' },
{ mimetype: 'video/x-matroska', extension: '.mkv' },
{ mimetype: 'video/x-ms-wmv', extension: '.wmv' },
{ mimetype: 'video/x-msvideo', extension: '.avi' },
]) {
it(`should map ${extension} to ${mimetype}`, async () => {
expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype);
});
}
describe('profile', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.profile).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
for (const [ext, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('image', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.image);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => {
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
});
for (const [ext, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only video mime types', () => {
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/')));
});
for (const [ext, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('sidecar', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.sidecar).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only xml mime types', () => {
expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']);
});
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
});

View File

@@ -30,70 +30,73 @@ export function assertMachineLearningEnabled() {
}
}
const profile: Record<string, string> = {
'.avif': 'image/avif',
'.dng': 'image/x-adobe-dng',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
const image: Record<string, string[]> = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
'.ari': ['image/ari', 'image/x-arriflex-ari'],
'.arw': ['image/arw', 'image/x-sony-arw'],
'.avif': ['image/avif'],
'.cap': ['image/cap', 'image/x-phaseone-cap'],
'.cin': ['image/cin', 'image/x-phantom-cin'],
'.cr2': ['image/cr2', 'image/x-canon-cr2'],
'.cr3': ['image/cr3', 'image/x-canon-cr3'],
'.crw': ['image/crw', 'image/x-canon-crw'],
'.dcr': ['image/dcr', 'image/x-kodak-dcr'],
'.dng': ['image/dng', 'image/x-adobe-dng'],
'.erf': ['image/erf', 'image/x-epson-erf'],
'.fff': ['image/fff', 'image/x-hasselblad-fff'],
'.gif': ['image/gif'],
'.heic': ['image/heic'],
'.heif': ['image/heif'],
'.iiq': ['image/iiq', 'image/x-phaseone-iiq'],
'.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.k25': ['image/k25', 'image/x-kodak-k25'],
'.kdc': ['image/kdc', 'image/x-kodak-kdc'],
'.mrw': ['image/mrw', 'image/x-minolta-mrw'],
'.nef': ['image/nef', 'image/x-nikon-nef'],
'.orf': ['image/orf', 'image/x-olympus-orf'],
'.ori': ['image/ori', 'image/x-olympus-ori'],
'.pef': ['image/pef', 'image/x-pentax-pef'],
'.png': ['image/png'],
'.raf': ['image/raf', 'image/x-fuji-raf'],
'.raw': ['image/raw', 'image/x-panasonic-raw'],
'.rwl': ['image/rwl', 'image/x-leica-rwl'],
'.sr2': ['image/sr2', 'image/x-sony-sr2'],
'.srf': ['image/srf', 'image/x-sony-srf'],
'.srw': ['image/srw', 'image/x-samsung-srw'],
'.tiff': ['image/tiff'],
'.webp': ['image/webp'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'],
};
const image: Record<string, string> = {
...profile,
'.3fr': 'image/x-hasselblad-3fr',
'.ari': 'image/x-arriflex-ari',
'.arw': 'image/x-sony-arw',
'.cap': 'image/x-phaseone-cap',
'.cin': 'image/x-phantom-cin',
'.cr2': 'image/x-canon-cr2',
'.cr3': 'image/x-canon-cr3',
'.crw': 'image/x-canon-crw',
'.dcr': 'image/x-kodak-dcr',
'.erf': 'image/x-epson-erf',
'.fff': 'image/x-hasselblad-fff',
'.gif': 'image/gif',
'.iiq': 'image/x-phaseone-iiq',
'.k25': 'image/x-kodak-k25',
'.kdc': 'image/x-kodak-kdc',
'.mrw': 'image/x-minolta-mrw',
'.nef': 'image/x-nikon-nef',
'.orf': 'image/x-olympus-orf',
'.ori': 'image/x-olympus-ori',
'.pef': 'image/x-pentax-pef',
'.raf': 'image/x-fuji-raf',
'.raw': 'image/x-panasonic-raw',
'.rwl': 'image/x-leica-rwl',
'.sr2': 'image/x-sony-sr2',
'.srf': 'image/x-sony-srf',
'.srw': 'image/x-samsung-srw',
'.tiff': 'image/tiff',
'.x3f': 'image/x-sigma-x3f',
const profileExtensions = ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'];
const profile: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => profileExtensions.includes(key)),
);
const video: Record<string, string[]> = {
'.3gp': ['video/3gpp'],
'.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'],
'.flv': ['video/x-flv'],
'.m2ts': ['video/mp2t'],
'.mkv': ['video/x-matroska'],
'.mov': ['video/quicktime'],
'.mp4': ['video/mp4'],
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
};
const video: Record<string, string> = {
'.3gp': 'video/3gpp',
'.avi': 'video/x-msvideo',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp2t': 'video/mp2t',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm',
'.wmv': 'video/x-ms-wmv',
const sidecar: Record<string, string[]> = {
'.xmp': ['application/xml', 'text/xml'],
};
const sidecar: Record<string, string> = {
'.xmp': 'application/xml',
};
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
const isType = (filename: string, lookup: Record<string, string>) => !!lookup[extname(filename).toLowerCase()];
const getType = (filename: string, lookup: Record<string, string>) => lookup[extname(filename).toLowerCase()];
const lookup = (filename: string) =>
getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream';
({ ...image, ...video, ...sidecar }[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream');
export const mimeTypes = {
image,
@@ -107,14 +110,12 @@ export const mimeTypes = {
isVideo: (filename: string) => isType(filename, video),
lookup,
assetType: (filename: string) => {
const contentType = lookup(filename).split('/')[0];
switch (contentType) {
case 'image':
return AssetType.IMAGE;
case 'video':
return AssetType.VIDEO;
default:
return AssetType.OTHER;
const contentType = lookup(filename);
if (contentType.startsWith('image/')) {
return AssetType.IMAGE;
} else if (contentType.startsWith('video/')) {
return AssetType.VIDEO;
}
return AssetType.OTHER;
},
};

View File

@@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition';
import { JobService } from './job';
import { MediaService } from './media';
import { MetadataService } from './metadata';
import { OAuthService } from './oauth';
import { PartnerService } from './partner';
import { PersonService } from './person';
import { SearchService } from './search';
@@ -29,7 +28,6 @@ const providers: Provider[] = [
JobService,
MediaService,
MetadataService,
OAuthService,
PersonService,
PartnerService,
SearchService,

View File

@@ -13,7 +13,6 @@ export * from './facial-recognition';
export * from './job';
export * from './media';
export * from './metadata';
export * from './oauth';
export * from './partner';
export * from './person';
export * from './search';
@@ -25,4 +24,3 @@ export * from './storage-template';
export * from './system-config';
export * from './tag';
export * from './user';
export * from './user-token';

View File

@@ -1,2 +0,0 @@
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';

View File

@@ -1,4 +0,0 @@
export * from './dto';
export * from './oauth.constants';
export * from './oauth.service';
export * from './response-dto';

View File

@@ -1 +0,0 @@
export const MOBILE_REDIRECT = 'app.immich:/';

View File

@@ -1,107 +0,0 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { OAuthConfigDto } from './dto';
import { MOBILE_REDIRECT } from './oauth.constants';
import { OAuthConfigResponseDto } from './response-dto';
type OAuthProfile = UserinfoResponse & {
email: string;
};
@Injectable()
export class OAuthCore {
private readonly logger = new Logger(OAuthCore.name);
private configCore: SystemConfigCore;
constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
this.configCore = new SystemConfigCore(configRepository);
custom.setHttpOptionsDefaults({
timeout: 30000,
});
this.configCore.config$.subscribe((config) => (this.config = config));
}
async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const response = {
enabled: this.config.oauth.enabled,
passwordLoginEnabled: this.config.passwordLogin.enabled,
};
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = this.config.oauth;
const url = (await this.getClient()).authorizationUrl({
redirect_uri: this.normalize(dto.redirectUri),
scope,
state: generators.state(),
});
return { ...response, buttonText, url, autoLaunch };
}
async callback(url: string): Promise<OAuthProfile> {
const redirectUri = this.normalize(url.split('?')[0]);
const client = await this.getClient();
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
}
isAutoRegisterEnabled() {
return this.config.oauth.autoRegister;
}
asUser(profile: OAuthProfile) {
return {
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
};
}
async getLogoutEndpoint(): Promise<string | null> {
if (!this.config.oauth.enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async getClient() {
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(redirectUri: string) {
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
}

View File

@@ -1,217 +0,0 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
authStub,
loginResponseStub,
newCryptoRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
newUserTokenRepositoryMock,
systemConfigStub,
userEntityStub,
userTokenEntityStub,
} from '@test';
import { generators, Issuer } from 'openid-client';
import { OAuthService } from '.';
import { LoginDetails } from '../auth';
import { ICryptoRepository } from '../crypto';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails: LoginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
deviceOS: '',
deviceType: '',
};
describe('OAuthService', () => {
let sut: OAuthService;
let userMock: jest.Mocked<IUserRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let userTokenMock: jest.Mocked<IUserTokenRepository>;
let callbackMock: jest.Mock;
let create: (config: SystemConfig) => OAuthService;
beforeEach(async () => {
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({
id_token_signing_alg_values_supported: ['HS256'],
Client: jest.fn().mockResolvedValue({
issuer: {
metadata: {
end_session_endpoint: 'http://end-session-endpoint',
},
},
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: callbackMock,
userinfo: jest.fn().mockResolvedValue({ sub, email }),
}),
} as any);
cryptoMock = newCryptoRepositoryMock();
configMock = newSystemConfigRepositoryMock();
userMock = newUserRepositoryMock();
userTokenMock = newUserTokenRepositoryMock();
create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
sut = create(systemConfigStub.disabled);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
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');
});
it('should work if called without query params', () => {
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
});
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: false,
});
});
it('should generate the config', async () => {
sut = create(systemConfigStub.enabled);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});
describe('login', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
sut = create(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
sut = create(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userMock.update.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
});
it('should allow auto registering by default', async () => {
sut = create(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
userMock.create.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userMock.create).toHaveBeenCalledTimes(1);
});
it('should use the mobile redirect override', async () => {
sut = create(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
sut = create(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
});
describe('link', () => {
it('should link an account', async () => {
sut = create(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
sut = create(systemConfigStub.enabled);
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
sut = create(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.unlink(authStub.user1);
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
});
});
describe('getLogoutEndpoint', () => {
it('should return null if OAuth is not configured', async () => {
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
});
it('should get the session endpoint from the discovery document', async () => {
sut = create(systemConfigStub.enabled);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});
});
});

View File

@@ -1,92 +0,0 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
import { AuthCore, LoginDetails } from '../auth/auth.core';
import { ICryptoRepository } from '../crypto';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore, UserResponseDto } from '../user';
import { IUserTokenRepository } from '../user-token';
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
import { MOBILE_REDIRECT } from './oauth.constants';
import { OAuthCore } from './oauth.core';
import { OAuthConfigResponseDto } from './response-dto';
@Injectable()
export class OAuthService {
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private readonly logger = new Logger(OAuthService.name);
constructor(
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
) {
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.userCore = new UserCore(userRepository, cryptoRepository);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
return this.oauthCore.generateConfig(dto);
}
async login(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const profile = await this.oauthCore.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!this.oauthCore.isAutoRegisterEnabled()) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
}
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const { sub: oauthId } = await this.oauthCore.callback(dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
public async getLogoutEndpoint(): Promise<string | null> {
return this.oauthCore.getLogoutEndpoint();
}
}

View File

@@ -1 +0,0 @@
export * from './oauth-config-response.dto';

View File

@@ -1,6 +1,7 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../domain.util';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
/**
@@ -16,6 +17,13 @@ export class PersonUpdateDto {
@IsOptional()
@IsString()
featureFaceAssetId?: string;
/**
* Person visibility
*/
@IsOptional()
@IsBoolean()
isHidden?: boolean;
}
export class MergePersonDto {
@@ -23,10 +31,23 @@ export class MergePersonDto {
ids!: string[];
}
export class PersonSearchDto {
@IsBoolean()
@Transform(toBoolean)
withHidden?: boolean = false;
}
export class PersonResponseDto {
id!: string;
name!: string;
thumbnailPath!: string;
isHidden!: boolean;
}
export class PeopleResponseDto {
total!: number;
visible!: number;
people!: PersonResponseDto[];
}
export function mapPerson(person: PersonEntity): PersonResponseDto {
@@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
};
}

View File

@@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
};
describe(PersonService.name, () => {
@@ -41,7 +42,37 @@ describe(PersonService.name, () => {
describe('getAll', () => {
it('should get all people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]);
await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
total: 1,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
total: 2,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
visible: 1,
people: [
responseDto,
{
id: 'person-1',
name: '',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
},
],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
});
@@ -111,6 +142,21 @@ describe(PersonService.name, () => {
});
});
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetEntityStub.image.id] },
});
});
it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1);

View File

@@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto';
import {
mapPerson,
MergePersonDto,
PeopleResponseDto,
PersonResponseDto,
PersonSearchDto,
PersonUpdateDto,
} from './person.dto';
import { IPersonRepository, UpdateFacesData } from './person.repository';
@Injectable()
@@ -17,16 +24,21 @@ export class PersonService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {}
async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> {
async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
const named = people.filter((person) => !!person.name);
const unnamed = people.filter((person) => !person.name);
return (
[...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person))
);
const persons: PersonResponseDto[] = [...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person));
return {
people: persons.filter((person) => dto.withHidden || !person.isHidden),
total: persons.length,
visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length,
};
}
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
@@ -50,8 +62,8 @@ export class PersonService {
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id);
if (dto.name) {
person = await this.repository.update({ id, name: dto.name });
if (dto.name != undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });

View File

@@ -25,3 +25,9 @@ export class ServerStatsResponseDto {
})
usageByUser: UsageByUserDto[] = [];
}
export class ServerMediaTypesResponseDto {
video!: string[];
image!: string[];
sidecar!: string[];
}

View File

@@ -1,9 +1,15 @@
import { Inject, Injectable } from '@nestjs/common';
import { serverVersion } from '../domain.constant';
import { mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IUserRepository, UserStatsQueryResponse } from '../user';
import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
import {
ServerInfoResponseDto,
ServerMediaTypesResponseDto,
ServerPingResponse,
ServerStatsResponseDto,
UsageByUserDto,
} from './response-dto';
@Injectable()
export class ServerInfoService {
@@ -60,4 +66,12 @@ export class ServerInfoService {
return serverStats;
}
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return {
video: [...Object.keys(mimeTypes.video)],
image: [...Object.keys(mimeTypes.image)],
sidecar: [...Object.keys(mimeTypes.sidecar)],
};
}
}

View File

@@ -4,7 +4,6 @@ import {
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userEntityStub,
} from '@test';
import { when } from 'jest-when';
@@ -12,6 +11,7 @@ import { StorageTemplateService } from '.';
import { IAssetRepository } from '../asset';
import { IStorageRepository } from '../storage/storage.repository';
import { ISystemConfigRepository } from '../system-config';
import { defaults } from '../system-config/system-config.core';
import { IUserRepository } from '../user';
describe(StorageTemplateService.name, () => {
@@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => {
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
});
describe('handle template migration', () => {

View File

@@ -25,6 +25,9 @@ export class SystemConfigOAuthDto {
@IsString()
scope!: string;
@IsString()
storageLabelClaim!: string;
@IsString()
buttonText!: string;

View File

@@ -16,7 +16,7 @@ import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults = Object.freeze<SystemConfig>({
export const defaults = Object.freeze<SystemConfig>({
ffmpeg: {
crf: 23,
threads: 0,
@@ -48,6 +48,7 @@ const defaults = Object.freeze<SystemConfig>({
mobileOverrideEnabled: false,
mobileRedirectUri: '',
scope: 'openid email profile',
storageLabelClaim: 'preferred_username',
buttonText: 'Login with OAuth',
autoRegister: true,
autoLaunch: false,

View File

@@ -7,9 +7,9 @@ import {
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test';
import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
import { IJobRepository, JobName, QueueName } from '../job';
import { SystemConfigValidator } from './system-config.core';
import { defaults, SystemConfigValidator } from './system-config.core';
import { ISystemConfigRepository } from './system-config.repository';
import { SystemConfigService } from './system-config.service';
@@ -53,6 +53,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
mobileOverrideEnabled: false,
mobileRedirectUri: '',
scope: 'openid email profile',
storageLabelClaim: 'preferred_username',
},
passwordLogin: {
enabled: true,
@@ -81,7 +82,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', () => {
configMock.load.mockResolvedValue(updates);
expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
expect(sut.getDefaults()).toEqual(defaults);
expect(configMock.load).not.toHaveBeenCalled();
});
});
@@ -89,12 +90,9 @@ describe(SystemConfigService.name, () => {
describe('addValidator', () => {
it('should call the validator on config changes', async () => {
const validator: SystemConfigValidator = jest.fn();
sut.addValidator(validator);
await sut.updateConfig(systemConfigStub.defaults);
expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
await sut.updateConfig(defaults);
expect(validator).toHaveBeenCalledWith(defaults);
});
});
@@ -102,7 +100,7 @@ describe(SystemConfigService.name, () => {
it('should return the default config', async () => {
configMock.load.mockResolvedValue([]);
await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
await expect(sut.getConfig()).resolves.toEqual(defaults);
});
it('should merge the overrides', async () => {
@@ -172,7 +170,7 @@ describe(SystemConfigService.name, () => {
await sut.refreshConfig();
expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
expect(changeMock).toHaveBeenCalledWith(defaults);
subscription.unsubscribe();
});

View File

@@ -1,2 +0,0 @@
export * from './user-token.core';
export * from './user-token.repository';

View File

@@ -1,57 +0,0 @@
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { LoginDetails } from '../auth';
import { ICryptoRepository } from '../crypto';
import { IUserTokenRepository } from './user-token.repository';
@Injectable()
export class UserTokenCore {
constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
async validate(tokenValue: string) {
const hashedToken = this.crypto.hashSha256(tokenValue);
let token = await this.repository.getByToken(hashedToken);
if (token?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(token.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
token = await this.repository.save({ ...token, updatedAt: new Date() });
}
return {
...token.user,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowExif: true,
accessTokenId: token.id,
};
}
throw new UnauthorizedException('Invalid user token');
}
async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
const token = this.crypto.hashSha256(key);
await this.repository.create({
token,
user,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
});
return key;
}
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete(userId, id);
}
getAll(userId: string): Promise<UserTokenEntity[]> {
return this.repository.getAll(userId);
}
}

View File

@@ -8,9 +8,9 @@ import {
} from '@nestjs/common';
import { constants, createReadStream, ReadStream } from 'fs';
import fs from 'fs/promises';
import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto';
import { IUserRepository, UserListFilter } from './user.repository';
const SALT_ROUNDS = 10;
@@ -67,13 +67,13 @@ export class UserCore {
}
}
async createUser(createUserDto: CreateUserDto | CreateAdminDto | CreateUserOAuthDto): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(createUserDto.email);
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
throw new BadRequestException('User exists');
}
if (!(createUserDto as CreateAdminDto).isAdmin) {
if (!dto.isAdmin) {
const localAdmin = await this.userRepository.getAdmin();
if (!localAdmin) {
throw new BadRequestException('The first registered account must the administrator.');
@@ -81,10 +81,13 @@ export class UserCore {
}
try {
const payload: Partial<UserEntity> = { ...createUserDto };
const payload: Partial<UserEntity> = { ...dto };
if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}
if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel);
}
return this.userRepository.create(payload);
} catch (e) {
Logger.error(e, 'Create new user');

View File

@@ -1,13 +1,6 @@
import {
ICryptoRepository,
IJobRepository,
IStorageRepository,
JobName,
mimeTypes,
UploadFieldName,
} from '@app/domain';
import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
@@ -102,57 +95,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
return [result1, result2];
};
const uploadFile = {
nullAuth: {
authUser: null,
fieldName: UploadFieldName.ASSET_DATA,
file: {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
return {
authUser: authStub.admin,
fieldName,
file: {
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
},
};
},
};
const uploadTests = [
{
label: 'asset',
fieldName: UploadFieldName.ASSET_DATA,
filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }),
invalid: ['.xml', '.html'],
},
{
label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
filetypes: Object.keys(mimeTypes.video),
invalid: ['.xml', '.html', '.jpg', '.jpeg'],
},
{
label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA,
filetypes: Object.keys(mimeTypes.sidecar),
invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'],
},
{
label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA,
filetypes: Object.keys(mimeTypes.profile),
invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'],
},
];
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@@ -197,158 +139,6 @@ describe('AssetService', () => {
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
});
describe('mime types linting', () => {
describe('profile', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.profile);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual([...keys].sort());
});
});
describe('image', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.image);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.image);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image).filter((key) => key in mimeTypes.profile === false);
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => {
expect(Object.values(mimeTypes.image)).toEqual(
Object.values(mimeTypes.image).filter((mimeType) => mimeType.startsWith('image/')),
);
});
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.video);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual([...keys].sort());
});
it('should contain only video mime types', () => {
expect(Object.values(mimeTypes.video)).toEqual(
Object.values(mimeTypes.video).filter((mimeType) => mimeType.startsWith('video/')),
);
});
});
describe('sidecar', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.sidecar);
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual([...keys].sort());
});
});
describe('sidecar', () => {
it('should contain only be xml mime type', () => {
expect(Object.values(mimeTypes.sidecar)).toEqual(
Object.values(mimeTypes.sidecar).filter((mimeType) => mimeType === 'application/xml'),
);
});
});
});
describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, filetypes, invalid } of uploadTests) {
describe(`${fieldName}`, () => {
for (const filetype of filetypes) {
it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
}
});
}
});
describe('getUploadFilename', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should be the original extension for asset upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
it('should be the mov extension for live photo upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
'random-uuid.mov',
);
});
it('should be the xmp extension for sidecar upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
'random-uuid.xmp',
);
});
it('should be the original extension for profile upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
});
describe('getUploadFolder', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id');
});
});
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();

View File

@@ -12,9 +12,6 @@ import {
mapAssetWithoutExif,
mimeTypes,
Permission,
StorageCore,
StorageFolder,
UploadFieldName,
UploadFile,
} from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra/entities';
@@ -30,10 +27,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Response as Res } from 'express';
import { constants } from 'fs';
import fs from 'fs/promises';
import path, { extname } from 'path';
import sanitize from 'sanitize-filename';
import path from 'path';
import { QueryFailedError, Repository } from 'typeorm';
import { UploadRequest } from '../../app.interceptor';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@@ -70,7 +65,6 @@ export class AssetService {
readonly logger = new Logger(AssetService.name);
private assetCore: AssetCore;
private access: AccessCore;
private storageCore = new StorageCore();
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@@ -84,69 +78,6 @@ export class AssetService {
this.access = new AccessCore(accessRepository);
}
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
case UploadFieldName.LIVE_PHOTO_DATA:
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
case UploadFieldName.SIDECAR_DATA:
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
case UploadFieldName.PROFILE_DATA:
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(authUser);
const originalExt = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
};
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
}
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);
return folder;
}
public async uploadFile(
authUser: AuthUserDto,
dto: CreateAssetDto,

View File

@@ -1,4 +1,4 @@
import { AuthUserDto, UploadFieldName, UploadFile } from '@app/domain';
import { AssetService, UploadFieldName, UploadFile } from '@app/domain';
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
@@ -7,7 +7,6 @@ import { createHash } from 'crypto';
import { NextFunction, RequestHandler } from 'express';
import multer, { diskStorage, StorageEngine } from 'multer';
import { Observable } from 'rxjs';
import { AssetService } from './api-v1/asset/asset.service';
import { AuthRequest } from './app.guard';
export enum Route {
@@ -43,12 +42,6 @@ const callbackify = async <T>(fn: (...args: any[]) => T, callback: Callback<T>)
}
};
export interface UploadRequest {
authUser: AuthUserDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
const asRequest = (req: AuthRequest, file: Express.Multer.File) => {
return {
authUser: req.user || null,

View File

@@ -1,11 +1,11 @@
import {
AuthService,
AuthUserDto,
LoginDetails,
LoginResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthConfigResponseDto,
OAuthService,
UserResponseDto,
} from '@app/domain';
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
@@ -19,7 +19,7 @@ import { UseValidation } from '../app.utils';
@Authenticated()
@UseValidation()
export class OAuthController {
constructor(private service: OAuthService) {}
constructor(private service: AuthService) {}
@PublicRoute()
@Get('mobile-redirect')
@@ -44,7 +44,7 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(dto, loginDetails);
const { response, cookie } = await this.service.callback(dto, loginDetails);
res.header('Set-Cookie', cookie);
return response;
}

View File

@@ -4,11 +4,13 @@ import {
BulkIdResponseDto,
ImmichReadStream,
MergePersonDto,
PeopleResponseDto,
PersonResponseDto,
PersonSearchDto,
PersonService,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils';
@@ -26,8 +28,8 @@ export class PersonController {
constructor(private service: PersonService) {}
@Get()
getAllPeople(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> {
return this.service.getAll(authUser);
getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(authUser, withHidden);
}
@Get(':id')

View File

@@ -1,6 +1,7 @@
import {
ServerInfoResponseDto,
ServerInfoService,
ServerMediaTypesResponseDto,
ServerPingResponse,
ServerStatsResponseDto,
ServerVersionReponseDto,
@@ -39,4 +40,10 @@ export class ServerInfoController {
getStats(): Promise<ServerStatsResponseDto> {
return this.service.getStats();
}
@PublicRoute()
@Get('/media-types')
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
}

View File

@@ -94,7 +94,7 @@ export class UserController {
}
@Get('/profile-image/:userId')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
@Header('Cache-Control', 'private, no-cache, no-transform')
async getProfileImage(@Param() { userId }: UserIdDto, @Response({ passthrough: true }) res: Res): Promise<any> {
const readableStream = await this.service.getUserProfileImage(userId);
res.header('Content-Type', 'image/jpeg');

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