Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1835fbae49 | ||
|
|
593489a14c | ||
|
|
9f7bf36786 | ||
|
|
f0302670d2 | ||
|
|
4b8cc7b533 | ||
|
|
6e953ff5eb | ||
|
|
7316ad5a72 | ||
|
|
f28fc8fa5c | ||
|
|
02b70e693c | ||
|
|
b2e06477f8 | ||
|
|
632971a2ac | ||
|
|
8045fd3f14 | ||
|
|
a2568f711f | ||
|
|
f9032866e7 | ||
|
|
e287b18435 | ||
|
|
c415ee82d1 | ||
|
|
c8f1a15f21 | ||
|
|
9012cf6946 | ||
|
|
7595d01956 | ||
|
|
ed3c239b7e | ||
|
|
c254a04aec | ||
|
|
d5b96c0257 | ||
|
|
436a2e9bf3 | ||
|
|
b34f4345e1 | ||
|
|
f55d63fae8 | ||
|
|
ed594c1987 | ||
|
|
ab85dd9fa8 | ||
|
|
08c7054845 | ||
|
|
9ef41bf1c7 | ||
|
|
1064128fde | ||
|
|
382341f550 | ||
|
|
81e07fda08 | ||
|
|
4c4435bc19 | ||
|
|
f952bc0b64 | ||
|
|
05e1a6d949 | ||
|
|
ea3d01ec62 |
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -2,12 +2,16 @@ mobile/openapi/**/*.md -diff -merge
|
||||
mobile/openapi/**/*.md linguist-generated=true
|
||||
mobile/openapi/**/*.dart -diff -merge
|
||||
mobile/openapi/**/*.dart linguist-generated=true
|
||||
mobile/openapi/.openapi-generator/FILES -diff -merge
|
||||
mobile/openapi/.openapi-generator/FILES linguist-generated=true
|
||||
|
||||
|
||||
cli/src/api/open-api/**/*.md -diff -merge
|
||||
cli/src/api/open-api/**/*.md linguist-generated=true
|
||||
cli/src/api/open-api/**/*.ts -diff -merge
|
||||
cli/src/api/open-api/**/*.ts linguist-generated=true
|
||||
|
||||
web/src/api/open-api/**/*.md -diff -merge
|
||||
web/src/api/open-api/**/*.md linguist-generated=true
|
||||
|
||||
web/src/api/open-api/**/*.ts -diff -merge
|
||||
web/src/api/open-api/**/*.ts linguist-generated=true
|
||||
|
||||
mobile/openapi/.openapi-generator/FILES -diff -merge
|
||||
mobile/openapi/.openapi-generator/FILES linguist-generated=true
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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
|
||||
|
||||
356
cli/src/api/open-api/api.ts
generated
356
cli/src/api/open-api/api.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.67.1
|
||||
* The version of the OpenAPI document: 1.68.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
@@ -486,43 +486,6 @@ export interface AssetCountByTimeBucketResponseDto {
|
||||
*/
|
||||
'buckets': Array<AssetCountByTimeBucket>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetCountByUserIdResponseDto
|
||||
*/
|
||||
export interface AssetCountByUserIdResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByUserIdResponseDto
|
||||
*/
|
||||
'audio': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByUserIdResponseDto
|
||||
*/
|
||||
'photos': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByUserIdResponseDto
|
||||
*/
|
||||
'videos': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByUserIdResponseDto
|
||||
*/
|
||||
'other': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetCountByUserIdResponseDto
|
||||
*/
|
||||
'total': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -724,6 +687,31 @@ export interface AssetResponseDto {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetStatsResponseDto
|
||||
*/
|
||||
export interface AssetStatsResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetStatsResponseDto
|
||||
*/
|
||||
'images': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetStatsResponseDto
|
||||
*/
|
||||
'videos': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetStatsResponseDto
|
||||
*/
|
||||
'total': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -1789,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
|
||||
@@ -1813,6 +1826,12 @@ export interface PersonResponseDto {
|
||||
* @memberof PersonResponseDto
|
||||
*/
|
||||
'thumbnailPath': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof PersonResponseDto
|
||||
*/
|
||||
'isHidden': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -1832,6 +1851,12 @@ export interface PersonUpdateDto {
|
||||
* @memberof PersonUpdateDto
|
||||
*/
|
||||
'featureFaceAssetId'?: string;
|
||||
/**
|
||||
* Person visibility
|
||||
* @type {boolean}
|
||||
* @memberof PersonUpdateDto
|
||||
*/
|
||||
'isHidden'?: boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -2097,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
|
||||
@@ -2608,6 +2658,12 @@ export interface SystemConfigOAuthDto {
|
||||
* @memberof SystemConfigOAuthDto
|
||||
*/
|
||||
'scope': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigOAuthDto
|
||||
*/
|
||||
'storageLabelClaim': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -4892,44 +4948,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
|
||||
|
||||
|
||||
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}
|
||||
*/
|
||||
getArchivedAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset/stat/archive`;
|
||||
// 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;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
@@ -5079,8 +5097,8 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset/count-by-user-id`;
|
||||
getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset/search-terms`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
@@ -5114,11 +5132,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {boolean} [isArchived]
|
||||
* @param {boolean} [isFavorite]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset/search-terms`;
|
||||
getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset/statistics`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
@@ -5139,6 +5159,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (isArchived !== undefined) {
|
||||
localVarQueryParameter['isArchived'] = isArchived;
|
||||
}
|
||||
|
||||
if (isFavorite !== undefined) {
|
||||
localVarQueryParameter['isFavorite'] = isFavorite;
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
@@ -5887,15 +5915,6 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getArchivedAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getArchivedAssetCountByUserId(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
* Get a single asset\'s information
|
||||
* @param {string} id
|
||||
@@ -5932,17 +5951,19 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options);
|
||||
async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {boolean} [isArchived]
|
||||
* @param {boolean} [isFavorite]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options);
|
||||
async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@@ -6160,14 +6181,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
||||
getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getArchivedAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise<AssetCountByUserIdResponseDto> {
|
||||
return localVarFp.getArchivedAssetCountByUserId(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get a single asset\'s information
|
||||
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
|
||||
@@ -6200,16 +6213,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise<AssetCountByUserIdResponseDto> {
|
||||
return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath));
|
||||
getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise<Array<string>> {
|
||||
return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise<Array<string>> {
|
||||
return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath));
|
||||
getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
|
||||
return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@@ -6523,6 +6537,27 @@ export interface AssetApiGetAssetCountByTimeBucketRequest {
|
||||
readonly getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for getAssetStats operation in AssetApi.
|
||||
* @export
|
||||
* @interface AssetApiGetAssetStatsRequest
|
||||
*/
|
||||
export interface AssetApiGetAssetStatsRequest {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof AssetApiGetAssetStats
|
||||
*/
|
||||
readonly isArchived?: boolean
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof AssetApiGetAssetStats
|
||||
*/
|
||||
readonly isFavorite?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for getAssetThumbnail operation in AssetApi.
|
||||
* @export
|
||||
@@ -6915,16 +6950,6 @@ export class AssetApi extends BaseAPI {
|
||||
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getArchivedAssetCountByUserId(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getArchivedAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single asset\'s information
|
||||
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
|
||||
@@ -6964,18 +6989,19 @@ export class AssetApi extends BaseAPI {
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAssetCountByUserId(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
|
||||
public getAssetSearchTerms(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAssetSearchTerms(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath));
|
||||
public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8655,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);
|
||||
@@ -8680,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);
|
||||
@@ -8925,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);
|
||||
},
|
||||
/**
|
||||
@@ -8996,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));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@@ -9050,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
|
||||
@@ -9143,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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9747,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};
|
||||
@@ -9822,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.
|
||||
@@ -9865,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.
|
||||
@@ -9913,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.
|
||||
|
||||
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.67.1
|
||||
* The version of the OpenAPI document: 1.68.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.67.1
|
||||
* The version of the OpenAPI document: 1.68.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.67.1
|
||||
* The version of the OpenAPI document: 1.68.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.67.1
|
||||
* The version of the OpenAPI document: 1.68.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
114
docker/docker-compose.prod.yml
Normal file
114
docker/docker-compose.prod.yml
Normal 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:
|
||||
@@ -51,8 +51,6 @@ services:
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
logging:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.67.1"
|
||||
version = "1.68.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 90,
|
||||
"android.injected.version.name" => "1.67.1",
|
||||
"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')
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.67.1"
|
||||
version_number: "1.68.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -93,7 +93,8 @@ Future<Isar> loadDb() async {
|
||||
DuplicatedAssetSchema,
|
||||
LoggerMessageSchema,
|
||||
ETagSchema,
|
||||
Platform.isAndroid ? AndroidDeviceAssetSchema : IOSDeviceAssetSchema,
|
||||
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
||||
if (Platform.isIOS) IOSDeviceAssetSchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 256,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 ?? [];
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,7 +33,6 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
if (route.name == 'SearchRoute') {
|
||||
// Refresh Location State
|
||||
ref.invalidate(getCuratedLocationProvider);
|
||||
ref.invalidate(getCuratedObjectProvider);
|
||||
ref.invalidate(getCuratedPeopleProvider);
|
||||
}
|
||||
|
||||
|
||||
12
mobile/openapi/.openapi-generator/FILES
generated
12
mobile/openapi/.openapi-generator/FILES
generated
@@ -23,11 +23,11 @@ doc/AssetBulkUploadCheckResponseDto.md
|
||||
doc/AssetBulkUploadCheckResult.md
|
||||
doc/AssetCountByTimeBucket.md
|
||||
doc/AssetCountByTimeBucketResponseDto.md
|
||||
doc/AssetCountByUserIdResponseDto.md
|
||||
doc/AssetFileUploadResponseDto.md
|
||||
doc/AssetIdsDto.md
|
||||
doc/AssetIdsResponseDto.md
|
||||
doc/AssetResponseDto.md
|
||||
doc/AssetStatsResponseDto.md
|
||||
doc/AssetTypeEnum.md
|
||||
doc/AudioCodec.md
|
||||
doc/AuthDeviceResponseDto.md
|
||||
@@ -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
|
||||
@@ -163,11 +165,11 @@ lib/model/asset_bulk_upload_check_response_dto.dart
|
||||
lib/model/asset_bulk_upload_check_result.dart
|
||||
lib/model/asset_count_by_time_bucket.dart
|
||||
lib/model/asset_count_by_time_bucket_response_dto.dart
|
||||
lib/model/asset_count_by_user_id_response_dto.dart
|
||||
lib/model/asset_file_upload_response_dto.dart
|
||||
lib/model/asset_ids_dto.dart
|
||||
lib/model/asset_ids_response_dto.dart
|
||||
lib/model/asset_response_dto.dart
|
||||
lib/model/asset_stats_response_dto.dart
|
||||
lib/model/asset_type_enum.dart
|
||||
lib/model/audio_codec.dart
|
||||
lib/model/auth_device_response_dto.dart
|
||||
@@ -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
|
||||
@@ -272,11 +276,11 @@ test/asset_bulk_upload_check_response_dto_test.dart
|
||||
test/asset_bulk_upload_check_result_test.dart
|
||||
test/asset_count_by_time_bucket_response_dto_test.dart
|
||||
test/asset_count_by_time_bucket_test.dart
|
||||
test/asset_count_by_user_id_response_dto_test.dart
|
||||
test/asset_file_upload_response_dto_test.dart
|
||||
test/asset_ids_dto_test.dart
|
||||
test/asset_ids_response_dto_test.dart
|
||||
test/asset_response_dto_test.dart
|
||||
test/asset_stats_response_dto_test.dart
|
||||
test/asset_type_enum_test.dart
|
||||
test/audio_codec_test.dart
|
||||
test/auth_device_response_dto_test.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
|
||||
|
||||
10
mobile/openapi/README.md
generated
10
mobile/openapi/README.md
generated
@@ -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.1
|
||||
- API version: 1.68.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
@@ -94,12 +94,11 @@ Class | Method | HTTP request | Description
|
||||
*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download |
|
||||
*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} |
|
||||
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
|
||||
*AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive |
|
||||
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} |
|
||||
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||
*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||
*AssetApi* | [**getAssetStats**](doc//AssetApi.md#getassetstats) | **GET** /asset/statistics |
|
||||
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
|
||||
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||
*AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects |
|
||||
@@ -142,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 |
|
||||
@@ -194,11 +194,11 @@ Class | Method | HTTP request | Description
|
||||
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
|
||||
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
|
||||
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
|
||||
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
|
||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||
- [AssetIdsDto](doc//AssetIdsDto.md)
|
||||
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
|
||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
|
||||
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
||||
- [AudioCodec](doc//AudioCodec.md)
|
||||
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
|
||||
@@ -238,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)
|
||||
@@ -252,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)
|
||||
|
||||
162
mobile/openapi/doc/AssetApi.md
generated
162
mobile/openapi/doc/AssetApi.md
generated
@@ -16,12 +16,11 @@ Method | HTTP request | Description
|
||||
[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download |
|
||||
[**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} |
|
||||
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
|
||||
[**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive |
|
||||
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} |
|
||||
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||
[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||
[**getAssetStats**](AssetApi.md#getassetstats) | **GET** /asset/statistics |
|
||||
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
|
||||
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||
[**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects |
|
||||
@@ -445,57 +444,6 @@ Name | Type | Description | Notes
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getArchivedAssetCountByUserId**
|
||||
> AssetCountByUserIdResponseDto getArchivedAssetCountByUserId()
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AssetApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getArchivedAssetCountByUserId();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getArchivedAssetCountByUserId: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAssetById**
|
||||
> AssetResponseDto getAssetById(id, key)
|
||||
|
||||
@@ -665,57 +613,6 @@ Name | Type | Description | Notes
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAssetCountByUserId**
|
||||
> AssetCountByUserIdResponseDto getAssetCountByUserId()
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AssetApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getAssetCountByUserId();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAssetSearchTerms**
|
||||
> List<String> getAssetSearchTerms()
|
||||
|
||||
@@ -767,6 +664,63 @@ 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)
|
||||
|
||||
# **getAssetStats**
|
||||
> AssetStatsResponseDto getAssetStats(isArchived, isFavorite)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AssetApi();
|
||||
final isArchived = true; // bool |
|
||||
final isFavorite = true; // bool |
|
||||
|
||||
try {
|
||||
final result = api_instance.getAssetStats(isArchived, isFavorite);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AssetApi->getAssetStats: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**isArchived** | **bool**| | [optional]
|
||||
**isFavorite** | **bool**| | [optional]
|
||||
|
||||
### Return type
|
||||
|
||||
[**AssetStatsResponseDto**](AssetStatsResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAssetThumbnail**
|
||||
> MultipartFile getAssetThumbnail(id, format, key)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# openapi.model.AssetCountByUserIdResponseDto
|
||||
# openapi.model.AssetStatsResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
@@ -8,11 +8,9 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**audio** | **int** | | [default to 0]
|
||||
**photos** | **int** | | [default to 0]
|
||||
**videos** | **int** | | [default to 0]
|
||||
**other** | **int** | | [default to 0]
|
||||
**total** | **int** | | [default to 0]
|
||||
**images** | **int** | |
|
||||
**videos** | **int** | |
|
||||
**total** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
17
mobile/openapi/doc/PeopleResponseDto.md
generated
Normal file
17
mobile/openapi/doc/PeopleResponseDto.md
generated
Normal 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)
|
||||
|
||||
|
||||
12
mobile/openapi/doc/PersonApi.md
generated
12
mobile/openapi/doc/PersonApi.md
generated
@@ -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
|
||||
|
||||
|
||||
1
mobile/openapi/doc/PersonResponseDto.md
generated
1
mobile/openapi/doc/PersonResponseDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
1
mobile/openapi/doc/PersonUpdateDto.md
generated
1
mobile/openapi/doc/PersonUpdateDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
38
mobile/openapi/doc/ServerInfoApi.md
generated
38
mobile/openapi/doc/ServerInfoApi.md
generated
@@ -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()
|
||||
|
||||
|
||||
17
mobile/openapi/doc/ServerMediaTypesResponseDto.md
generated
Normal file
17
mobile/openapi/doc/ServerMediaTypesResponseDto.md
generated
Normal 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)
|
||||
|
||||
|
||||
1
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
1
mobile/openapi/doc/SystemConfigOAuthDto.md
generated
@@ -13,6 +13,7 @@ Name | Type | Description | Notes
|
||||
**clientId** | **String** | |
|
||||
**clientSecret** | **String** | |
|
||||
**scope** | **String** | |
|
||||
**storageLabelClaim** | **String** | |
|
||||
**buttonText** | **String** | |
|
||||
**autoRegister** | **bool** | |
|
||||
**autoLaunch** | **bool** | |
|
||||
|
||||
4
mobile/openapi/lib/api.dart
generated
4
mobile/openapi/lib/api.dart
generated
@@ -60,11 +60,11 @@ part 'model/asset_bulk_upload_check_response_dto.dart';
|
||||
part 'model/asset_bulk_upload_check_result.dart';
|
||||
part 'model/asset_count_by_time_bucket.dart';
|
||||
part 'model/asset_count_by_time_bucket_response_dto.dart';
|
||||
part 'model/asset_count_by_user_id_response_dto.dart';
|
||||
part 'model/asset_file_upload_response_dto.dart';
|
||||
part 'model/asset_ids_dto.dart';
|
||||
part 'model/asset_ids_response_dto.dart';
|
||||
part 'model/asset_response_dto.dart';
|
||||
part 'model/asset_stats_response_dto.dart';
|
||||
part 'model/asset_type_enum.dart';
|
||||
part 'model/audio_codec.dart';
|
||||
part 'model/auth_device_response_dto.dart';
|
||||
@@ -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';
|
||||
|
||||
140
mobile/openapi/lib/api/asset_api.dart
generated
140
mobile/openapi/lib/api/asset_api.dart
generated
@@ -440,47 +440,6 @@ class AssetApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/stat/archive' operation and returns the [Response].
|
||||
Future<Response> getArchivedAssetCountByUserIdWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/stat/archive';
|
||||
|
||||
// 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<AssetCountByUserIdResponseDto?> getArchivedAssetCountByUserId() async {
|
||||
final response = await getArchivedAssetCountByUserIdWithHttpInfo();
|
||||
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), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get a single asset's information
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
@@ -639,47 +598,6 @@ class AssetApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response].
|
||||
Future<Response> getAssetCountByUserIdWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/count-by-user-id';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AssetCountByUserIdResponseDto?> getAssetCountByUserId() async {
|
||||
final response = await getAssetCountByUserIdWithHttpInfo();
|
||||
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), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
|
||||
Future<Response> getAssetSearchTermsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
@@ -724,6 +642,64 @@ class AssetApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [bool] isArchived:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
Future<Response> getAssetStatsWithHttpInfo({ bool? isArchived, bool? isFavorite, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/asset/statistics';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (isArchived != null) {
|
||||
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
|
||||
}
|
||||
if (isFavorite != null) {
|
||||
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [bool] isArchived:
|
||||
///
|
||||
/// * [bool] isFavorite:
|
||||
Future<AssetStatsResponseDto?> getAssetStats({ bool? isArchived, bool? isFavorite, }) async {
|
||||
final response = await getAssetStatsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, );
|
||||
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), 'AssetStatsResponseDto',) as AssetStatsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
|
||||
23
mobile/openapi/lib/api/person_api.dart
generated
23
mobile/openapi/lib/api/person_api.dart
generated
@@ -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;
|
||||
}
|
||||
|
||||
41
mobile/openapi/lib/api/server_info_api.dart
generated
41
mobile/openapi/lib/api/server_info_api.dart
generated
@@ -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
|
||||
|
||||
8
mobile/openapi/lib/api_client.dart
generated
8
mobile/openapi/lib/api_client.dart
generated
@@ -215,8 +215,6 @@ class ApiClient {
|
||||
return AssetCountByTimeBucket.fromJson(value);
|
||||
case 'AssetCountByTimeBucketResponseDto':
|
||||
return AssetCountByTimeBucketResponseDto.fromJson(value);
|
||||
case 'AssetCountByUserIdResponseDto':
|
||||
return AssetCountByUserIdResponseDto.fromJson(value);
|
||||
case 'AssetFileUploadResponseDto':
|
||||
return AssetFileUploadResponseDto.fromJson(value);
|
||||
case 'AssetIdsDto':
|
||||
@@ -225,6 +223,8 @@ class ApiClient {
|
||||
return AssetIdsResponseDto.fromJson(value);
|
||||
case 'AssetResponseDto':
|
||||
return AssetResponseDto.fromJson(value);
|
||||
case 'AssetStatsResponseDto':
|
||||
return AssetStatsResponseDto.fromJson(value);
|
||||
case 'AssetTypeEnum':
|
||||
return AssetTypeEnumTypeTransformer().decode(value);
|
||||
case 'AudioCodec':
|
||||
@@ -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':
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//
|
||||
// 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 AssetCountByUserIdResponseDto {
|
||||
/// Returns a new [AssetCountByUserIdResponseDto] instance.
|
||||
AssetCountByUserIdResponseDto({
|
||||
this.audio = 0,
|
||||
this.photos = 0,
|
||||
this.videos = 0,
|
||||
this.other = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
|
||||
int audio;
|
||||
|
||||
int photos;
|
||||
|
||||
int videos;
|
||||
|
||||
int other;
|
||||
|
||||
int total;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetCountByUserIdResponseDto &&
|
||||
other.audio == audio &&
|
||||
other.photos == photos &&
|
||||
other.videos == videos &&
|
||||
other.other == other &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(audio.hashCode) +
|
||||
(photos.hashCode) +
|
||||
(videos.hashCode) +
|
||||
(other.hashCode) +
|
||||
(total.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetCountByUserIdResponseDto[audio=$audio, photos=$photos, videos=$videos, other=$other, total=$total]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'audio'] = this.audio;
|
||||
json[r'photos'] = this.photos;
|
||||
json[r'videos'] = this.videos;
|
||||
json[r'other'] = this.other;
|
||||
json[r'total'] = this.total;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetCountByUserIdResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetCountByUserIdResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetCountByUserIdResponseDto(
|
||||
audio: mapValueOfType<int>(json, r'audio')!,
|
||||
photos: mapValueOfType<int>(json, r'photos')!,
|
||||
videos: mapValueOfType<int>(json, r'videos')!,
|
||||
other: mapValueOfType<int>(json, r'other')!,
|
||||
total: mapValueOfType<int>(json, r'total')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetCountByUserIdResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetCountByUserIdResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetCountByUserIdResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetCountByUserIdResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetCountByUserIdResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetCountByUserIdResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetCountByUserIdResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetCountByUserIdResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetCountByUserIdResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetCountByUserIdResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'audio',
|
||||
'photos',
|
||||
'videos',
|
||||
'other',
|
||||
'total',
|
||||
};
|
||||
}
|
||||
|
||||
114
mobile/openapi/lib/model/asset_stats_response_dto.dart
generated
Normal file
114
mobile/openapi/lib/model/asset_stats_response_dto.dart
generated
Normal 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 AssetStatsResponseDto {
|
||||
/// Returns a new [AssetStatsResponseDto] instance.
|
||||
AssetStatsResponseDto({
|
||||
required this.images,
|
||||
required this.videos,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
int images;
|
||||
|
||||
int videos;
|
||||
|
||||
int total;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetStatsResponseDto &&
|
||||
other.images == images &&
|
||||
other.videos == videos &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(images.hashCode) +
|
||||
(videos.hashCode) +
|
||||
(total.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetStatsResponseDto[images=$images, videos=$videos, total=$total]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'images'] = this.images;
|
||||
json[r'videos'] = this.videos;
|
||||
json[r'total'] = this.total;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetStatsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetStatsResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetStatsResponseDto(
|
||||
images: mapValueOfType<int>(json, r'images')!,
|
||||
videos: mapValueOfType<int>(json, r'videos')!,
|
||||
total: mapValueOfType<int>(json, r'total')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetStatsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetStatsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetStatsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetStatsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetStatsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetStatsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetStatsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetStatsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetStatsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'images',
|
||||
'videos',
|
||||
'total',
|
||||
};
|
||||
}
|
||||
|
||||
114
mobile/openapi/lib/model/people_response_dto.dart
generated
Normal file
114
mobile/openapi/lib/model/people_response_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
14
mobile/openapi/lib/model/person_response_dto.dart
generated
14
mobile/openapi/lib/model/person_response_dto.dart
generated
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
24
mobile/openapi/lib/model/person_update_dto.dart
generated
24
mobile/openapi/lib/model/person_update_dto.dart
generated
@@ -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;
|
||||
|
||||
120
mobile/openapi/lib/model/server_media_types_response_dto.dart
generated
Normal file
120
mobile/openapi/lib/model/server_media_types_response_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
13
mobile/openapi/test/asset_api_test.dart
generated
13
mobile/openapi/test/asset_api_test.dart
generated
@@ -60,11 +60,6 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<AssetCountByUserIdResponseDto> getArchivedAssetCountByUserId() async
|
||||
test('test getArchivedAssetCountByUserId', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// Get a single asset's information
|
||||
//
|
||||
//Future<AssetResponseDto> getAssetById(String id, { String key }) async
|
||||
@@ -82,13 +77,13 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<AssetCountByUserIdResponseDto> getAssetCountByUserId() async
|
||||
test('test getAssetCountByUserId', () async {
|
||||
//Future<List<String>> getAssetSearchTerms() async
|
||||
test('test getAssetSearchTerms', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<String>> getAssetSearchTerms() async
|
||||
test('test getAssetSearchTerms', () async {
|
||||
//Future<AssetStatsResponseDto> getAssetStats({ bool isArchived, bool isFavorite }) async
|
||||
test('test getAssetStats', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
@@ -11,32 +11,22 @@
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AssetCountByUserIdResponseDto
|
||||
// tests for AssetStatsResponseDto
|
||||
void main() {
|
||||
// final instance = AssetCountByUserIdResponseDto();
|
||||
// final instance = AssetStatsResponseDto();
|
||||
|
||||
group('test AssetCountByUserIdResponseDto', () {
|
||||
// int audio (default value: 0)
|
||||
test('to test the property `audio`', () async {
|
||||
group('test AssetStatsResponseDto', () {
|
||||
// int images
|
||||
test('to test the property `images`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int photos (default value: 0)
|
||||
test('to test the property `photos`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int videos (default value: 0)
|
||||
// int videos
|
||||
test('to test the property `videos`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int other (default value: 0)
|
||||
test('to test the property `other`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int total (default value: 0)
|
||||
// int total
|
||||
test('to test the property `total`', () async {
|
||||
// TODO
|
||||
});
|
||||
37
mobile/openapi/test/people_response_dto_test.dart
generated
Normal file
37
mobile/openapi/test/people_response_dto_test.dart
generated
Normal 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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
2
mobile/openapi/test/person_api_test.dart
generated
2
mobile/openapi/test/person_api_test.dart
generated
@@ -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
|
||||
});
|
||||
|
||||
@@ -31,6 +31,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool isHidden
|
||||
test('to test the property `isHidden`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
6
mobile/openapi/test/person_update_dto_test.dart
generated
6
mobile/openapi/test/person_update_dto_test.dart
generated
@@ -28,6 +28,12 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// Person visibility
|
||||
// bool isHidden
|
||||
test('to test the property `isHidden`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
5
mobile/openapi/test/server_info_api_test.dart
generated
5
mobile/openapi/test/server_info_api_test.dart
generated
@@ -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
|
||||
|
||||
37
mobile/openapi/test/server_media_types_response_dto_test.dart
generated
Normal file
37
mobile/openapi/test/server_media_types_response_dto_test.dart
generated
Normal 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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.67.1+90
|
||||
version: 1.68.0+91
|
||||
isar_version: &isar_version 3.1.0+1
|
||||
|
||||
environment:
|
||||
|
||||
@@ -984,38 +984,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/count-by-user-id": {
|
||||
"get": {
|
||||
"operationId": "getAssetCountByUserId",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetCountByUserIdResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Asset"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/curated-locations": {
|
||||
"get": {
|
||||
"operationId": "getCuratedLocations",
|
||||
@@ -1608,17 +1576,34 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/stat/archive": {
|
||||
"/asset/statistics": {
|
||||
"get": {
|
||||
"operationId": "getArchivedAssetCountByUserId",
|
||||
"parameters": [],
|
||||
"operationId": "getAssetStats",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "isArchived",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isFavorite",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetCountByUserIdResponseDto"
|
||||
"$ref": "#/components/schemas/AssetStatsResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2524,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3055,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",
|
||||
@@ -4383,7 +4396,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.67.1",
|
||||
"version": "1.68.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -4786,38 +4799,6 @@
|
||||
"buckets"
|
||||
]
|
||||
},
|
||||
"AssetCountByUserIdResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"photos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"other": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"audio",
|
||||
"photos",
|
||||
"videos",
|
||||
"other",
|
||||
"total"
|
||||
]
|
||||
},
|
||||
"AssetFileUploadResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4970,6 +4951,25 @@
|
||||
"checksum"
|
||||
]
|
||||
},
|
||||
"AssetStatsResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"images": {
|
||||
"type": "integer"
|
||||
},
|
||||
"videos": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"images",
|
||||
"videos",
|
||||
"total"
|
||||
]
|
||||
},
|
||||
"AssetTypeEnum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -5884,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": {
|
||||
@@ -5895,12 +5917,16 @@
|
||||
},
|
||||
"thumbnailPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"thumbnailPath"
|
||||
"thumbnailPath",
|
||||
"isHidden"
|
||||
]
|
||||
},
|
||||
"PersonUpdateDto": {
|
||||
@@ -5913,6 +5939,10 @@
|
||||
"featureFaceAssetId": {
|
||||
"type": "string",
|
||||
"description": "Asset is used to get the feature face thumbnail."
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean",
|
||||
"description": "Person visibility"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6146,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": {
|
||||
@@ -6531,6 +6589,9 @@
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
"storageLabelClaim": {
|
||||
"type": "string"
|
||||
},
|
||||
"buttonText": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6553,6 +6614,7 @@
|
||||
"clientId",
|
||||
"clientSecret",
|
||||
"scope",
|
||||
"storageLabelClaim",
|
||||
"buttonText",
|
||||
"autoRegister",
|
||||
"autoLaunch",
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.67.1",
|
||||
"version": "1.68.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.67.1",
|
||||
"version": "1.68.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.67.1",
|
||||
"version": "1.68.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { Paginated, PaginationOptions } from '../domain.util';
|
||||
|
||||
export type AssetStats = Record<AssetType, number>;
|
||||
|
||||
export interface AssetStatsOptions {
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface AssetSearchOptions {
|
||||
isVisible?: boolean;
|
||||
type?: AssetType;
|
||||
@@ -55,4 +62,5 @@ export interface IAssetRepository {
|
||||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
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 { IAssetRepository } from './asset.repository';
|
||||
import { AssetService } from './asset.service';
|
||||
import { DownloadResponseDto } from './index';
|
||||
import { AssetStats, IAssetRepository } from './asset.repository';
|
||||
import { AssetService, UploadFieldName } from './asset.service';
|
||||
import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
|
||||
import { mapAsset } from './response-dto';
|
||||
|
||||
const downloadResponse: DownloadResponseDto = {
|
||||
@@ -25,10 +28,123 @@ const downloadResponse: DownloadResponseDto = {
|
||||
],
|
||||
};
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
[AssetType.VIDEO]: 23,
|
||||
[AssetType.AUDIO]: 0,
|
||||
[AssetType.OTHER]: 0,
|
||||
};
|
||||
|
||||
const statResponse: AssetStatsResponseDto = {
|
||||
images: 10,
|
||||
videos: 23,
|
||||
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', () => {
|
||||
@@ -38,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', () => {
|
||||
@@ -287,4 +488,30 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: false });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for archived assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for favorite assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isFavorite: true });
|
||||
});
|
||||
|
||||
it('should get the statistics for a user for all assets', async () => {
|
||||
assetMock.getStatistics.mockResolvedValue(stats);
|
||||
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
|
||||
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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';
|
||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||
import { mapAsset, MapMarkerResponseDto } from './response-dto';
|
||||
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
|
||||
@@ -20,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;
|
||||
@@ -27,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);
|
||||
}
|
||||
@@ -155,4 +230,9 @@ export class AssetService {
|
||||
|
||||
throw new BadRequestException('assetIds, albumId, or userId is required');
|
||||
}
|
||||
|
||||
async getStatistics(authUser: AuthUserDto, dto: AssetStatsDto) {
|
||||
const stats = await this.assetRepository.getStatistics(authUser.id, dto);
|
||||
return mapStats(stats);
|
||||
}
|
||||
}
|
||||
|
||||
37
server/src/domain/asset/dto/asset-statistics.dto.ts
Normal file
37
server/src/domain/asset/dto/asset-statistics.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { toBoolean } from '../../domain.util';
|
||||
import { AssetStats } from '../asset.repository';
|
||||
|
||||
export class AssetStatsDto {
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
@IsOptional()
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
@IsOptional()
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class AssetStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
images!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
videos!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
return {
|
||||
images: stats[AssetType.IMAGE],
|
||||
videos: stats[AssetType.VIDEO],
|
||||
total: Object.values(stats).reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './asset-ids.dto';
|
||||
export * from './asset-statistics.dto';
|
||||
export * from './download.dto';
|
||||
export * from './map-marker.dto';
|
||||
export * from './memory-lane.dto';
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
191
server/src/domain/domain.constant.spec.ts
Normal file
191
server/src/domain/domain.constant.spec.ts
Normal 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]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './oauth-auth-code.dto';
|
||||
export * from './oauth-config.dto';
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './dto';
|
||||
export * from './oauth.constants';
|
||||
export * from './oauth.service';
|
||||
export * from './response-dto';
|
||||
@@ -1 +0,0 @@
|
||||
export const MOBILE_REDIRECT = 'app.immich:/';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './oauth-config-response.dto';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -25,3 +25,9 @@ export class ServerStatsResponseDto {
|
||||
})
|
||||
usageByUser: UsageByUserDto[] = [];
|
||||
}
|
||||
|
||||
export class ServerMediaTypesResponseDto {
|
||||
video!: string[];
|
||||
image!: string[];
|
||||
sidecar!: string[];
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -25,6 +25,9 @@ export class SystemConfigOAuthDto {
|
||||
@IsString()
|
||||
scope!: string;
|
||||
|
||||
@IsString()
|
||||
storageLabelClaim!: string;
|
||||
|
||||
@IsString()
|
||||
buttonText!: string;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user