feat(server): improve validation of albums (#2188)

* feat(server): improve validation of albums

* regenerate openapi + fix downloadArchive for web
This commit is contained in:
Michel Heusschen 2023-04-06 19:50:55 +02:00 committed by GitHub
parent b03ce897c7
commit 8e3a7caebd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 129 additions and 66 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,16 +1,4 @@
import { import { Controller, Get, Post, Body, Patch, Param, Delete, Put, Query, Response } from '@nestjs/common';
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ValidationPipe,
Put,
Query,
Response,
} from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe'; import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
import { CreateAlbumDto } from './dto/create-album.dto'; import { CreateAlbumDto } from './dto/create-album.dto';
@ -33,9 +21,11 @@ import {
import { DownloadDto } from '../asset/dto/download-library.dto'; import { DownloadDto } from '../asset/dto/download-library.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto'; import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { AlbumIdDto } from './dto/album-id.dto'; import { AlbumIdDto } from './dto/album-id.dto';
import { UseValidation } from '../../decorators/use-validation.decorator';
@ApiTags('Album') @ApiTags('Album')
@Controller('album') @Controller('album')
@UseValidation()
export class AlbumController { export class AlbumController {
constructor(private readonly albumService: AlbumService) {} constructor(private readonly albumService: AlbumService) {}
@ -47,7 +37,8 @@ export class AlbumController {
@Authenticated() @Authenticated()
@Post() @Post()
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) { async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body() createAlbumDto: CreateAlbumDto) {
// TODO: Handle nonexistent sharedWithUserIds and assetIds.
return this.albumService.create(authUser, createAlbumDto); return this.albumService.create(authUser, createAlbumDto);
} }
@ -55,9 +46,10 @@ export class AlbumController {
@Put('/:albumId/users') @Put('/:albumId/users')
async addUsersToAlbum( async addUsersToAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) addUsersDto: AddUsersDto, @Body() addUsersDto: AddUsersDto,
@Param() { albumId }: AlbumIdDto, @Param() { albumId }: AlbumIdDto,
) { ) {
// TODO: Handle nonexistent sharedUserIds.
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId); return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
} }
@ -65,9 +57,11 @@ export class AlbumController {
@Put('/:albumId/assets') @Put('/:albumId/assets')
async addAssetsToAlbum( async addAssetsToAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) addAssetsDto: AddAssetsDto, @Body() addAssetsDto: AddAssetsDto,
@Param() { albumId }: AlbumIdDto, @Param() { albumId }: AlbumIdDto,
): Promise<AddAssetsResponseDto> { ): Promise<AddAssetsResponseDto> {
// TODO: Handle nonexistent assetIds.
// TODO: Disallow adding assets of another user to an album.
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
} }
@ -81,7 +75,7 @@ export class AlbumController {
@Delete('/:albumId/assets') @Delete('/:albumId/assets')
async removeAssetFromAlbum( async removeAssetFromAlbum(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto, @Body() removeAssetsDto: RemoveAssetsDto,
@Param() { albumId }: AlbumIdDto, @Param() { albumId }: AlbumIdDto,
): Promise<AlbumResponseDto> { ): Promise<AlbumResponseDto> {
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId); return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
@ -107,9 +101,11 @@ export class AlbumController {
@Patch('/:albumId') @Patch('/:albumId')
async updateAlbumInfo( async updateAlbumInfo(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto, @Body() updateAlbumInfoDto: UpdateAlbumDto,
@Param() { albumId }: AlbumIdDto, @Param() { albumId }: AlbumIdDto,
) { ) {
// TODO: Handle nonexistent albumThumbnailAssetId.
// TODO: Disallow setting asset from other user as albumThumbnailAssetId.
return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId); return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
} }
@ -119,7 +115,7 @@ export class AlbumController {
async downloadArchive( async downloadArchive(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Param() { albumId }: AlbumIdDto, @Param() { albumId }: AlbumIdDto,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto, @Query() dto: DownloadDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
) { ) {
this.albumService.checkDownloadAccess(authUser); this.albumService.checkDownloadAccess(authUser);
@ -140,7 +136,7 @@ export class AlbumController {
@Post('/create-shared-link') @Post('/create-shared-link')
async createAlbumSharedLink( async createAlbumSharedLink(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) createAlbumShareLinkDto: CreateAlbumSharedLinkDto, @Body() createAlbumShareLinkDto: CreateAlbumSharedLinkDto,
) { ) {
return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto); return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto);
} }

View file

@ -1,6 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
export class AddAssetsDto { export class AddAssetsDto {
@IsNotEmpty() @ValidateUUID({ each: true })
assetIds!: string[]; assetIds!: string[];
} }

View file

@ -1,6 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
export class AddUsersDto { export class AddUsersDto {
@IsNotEmpty() @ValidateUUID({ each: true })
sharedUserIds!: string[]; sharedUserIds!: string[];
} }

View file

@ -1,9 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
import { IsNotEmpty, IsUUID } from 'class-validator';
export class AlbumIdDto { export class AlbumIdDto {
@IsNotEmpty() @ValidateUUID()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
albumId!: string; albumId!: string;
} }

View file

@ -1,27 +1,33 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
import { IsBoolean, IsISO8601, IsOptional, IsString } from 'class-validator';
export class CreateAlbumShareLinkDto { export class CreateAlbumShareLinkDto {
@IsString() @ValidateUUID()
@IsNotEmpty()
albumId!: string; albumId!: string;
@IsString() @IsISO8601()
@IsOptional() @IsOptional()
@ApiProperty({ format: 'date-time' })
expiresAt?: string; expiresAt?: string;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@ApiProperty()
allowUpload?: boolean; allowUpload?: boolean;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@ApiProperty()
allowDownload?: boolean; allowDownload?: boolean;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@ApiProperty()
showExif?: boolean; showExif?: boolean;
@IsString() @IsString()
@IsOptional() @IsOptional()
@ApiProperty()
description?: string; description?: string;
} }

View file

@ -1,12 +1,16 @@
import { IsNotEmpty, IsOptional } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateAlbumDto { export class CreateAlbumDto {
@IsNotEmpty() @IsNotEmpty()
@IsString()
@ApiProperty()
albumName!: string; albumName!: string;
@IsOptional() @ValidateUUID({ optional: true, each: true })
sharedWithUserIds?: string[]; sharedWithUserIds?: string[];
@IsOptional() @ValidateUUID({ optional: true, each: true })
assetIds?: string[]; assetIds?: string[];
} }

View file

@ -1,6 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
export class RemoveAssetsDto { export class RemoveAssetsDto {
@IsNotEmpty() @ValidateUUID({ each: true })
assetIds!: string[]; assetIds!: string[];
} }

View file

@ -1,9 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
import { IsOptional } from 'class-validator'; import { IsOptional } from 'class-validator';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@IsOptional() @IsOptional()
@ApiProperty()
albumName?: string; albumName?: string;
@IsOptional() @ValidateUUID({ optional: true })
albumThumbnailAssetId?: string; albumThumbnailAssetId?: string;
} }

View file

@ -4,7 +4,7 @@ import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class DownloadDto { export class DownloadDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
name = ''; name?: string;
@IsOptional() @IsOptional()
@IsPositive() @IsPositive()

View file

@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export type Options = {
optional?: boolean;
each?: boolean;
};
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
return applyDecorators(
IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }),
optional ? IsOptional() : IsNotEmpty(),
each ? IsArray() : IsString(),
);
}

View file

@ -1895,6 +1895,14 @@
"operationId": "downloadLibrary", "operationId": "downloadLibrary",
"description": "Current this is not used in any UI element", "description": "Current this is not used in any UI element",
"parameters": [ "parameters": [
{
"name": "name",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{ {
"name": "skip", "name": "skip",
"required": false, "required": false,
@ -3343,6 +3351,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "name",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{ {
"name": "skip", "name": "skip",
"required": false, "required": false,
@ -5359,7 +5375,8 @@
"assetIds": { "assetIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "uuid"
} }
} }
}, },
@ -5373,7 +5390,8 @@
"assetIds": { "assetIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "uuid"
} }
} }
}, },
@ -5435,13 +5453,15 @@
"sharedWithUserIds": { "sharedWithUserIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "uuid"
} }
}, },
"assetIds": { "assetIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "uuid"
} }
} }
}, },
@ -5455,7 +5475,8 @@
"sharedUserIds": { "sharedUserIds": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "uuid"
} }
} }
}, },
@ -5491,7 +5512,8 @@
"type": "string" "type": "string"
}, },
"albumThumbnailAssetId": { "albumThumbnailAssetId": {
"type": "string" "type": "string",
"format": "uuid"
} }
} }
}, },
@ -5499,10 +5521,12 @@
"type": "object", "type": "object",
"properties": { "properties": {
"albumId": { "albumId": {
"type": "string" "type": "string",
"format": "uuid"
}, },
"expiresAt": { "expiresAt": {
"type": "string" "type": "string",
"format": "date-time"
}, },
"allowUpload": { "allowUpload": {
"type": "boolean" "type": "boolean"

View file

@ -1,7 +1,8 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; import { IsBoolean, IsOptional } from 'class-validator';
import { toBoolean } from 'apps/immich/src/utils/transform.util'; import { toBoolean } from 'apps/immich/src/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from 'apps/immich/src/decorators/validate-uuid.decorator';
export class GetAlbumsDto { export class GetAlbumsDto {
@IsOptional() @IsOptional()
@ -20,8 +21,6 @@ export class GetAlbumsDto {
* Ignores the shared parameter * Ignores the shared parameter
* undefined: get all albums * undefined: get all albums
*/ */
@IsOptional() @ValidateUUID({ optional: true })
@IsUUID(4)
@ApiProperty({ format: 'uuid' })
assetId?: string; assetId?: string;
} }

View file

@ -3160,12 +3160,13 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} albumId * @param {string} albumId
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
downloadArchive: async (albumId: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { downloadArchive: async (albumId: string, name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined // verify required parameter 'albumId' is not null or undefined
assertParamExists('downloadArchive', 'albumId', albumId) assertParamExists('downloadArchive', 'albumId', albumId)
const localVarPath = `/album/{albumId}/download` const localVarPath = `/album/{albumId}/download`
@ -3187,6 +3188,10 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
// authentication cookie required // authentication cookie required
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
if (skip !== undefined) { if (skip !== undefined) {
localVarQueryParameter['skip'] = skip; localVarQueryParameter['skip'] = skip;
} }
@ -3529,13 +3534,14 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} albumId * @param {string} albumId
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async downloadArchive(albumId: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> { async downloadArchive(albumId: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(albumId, skip, key, options); const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(albumId, name, skip, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -3663,13 +3669,14 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
/** /**
* *
* @param {string} albumId * @param {string} albumId
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
downloadArchive(albumId: string, skip?: number, key?: string, options?: any): AxiosPromise<any> { downloadArchive(albumId: string, name?: string, skip?: number, key?: string, options?: any): AxiosPromise<any> {
return localVarFp.downloadArchive(albumId, skip, key, options).then((request) => request(axios, basePath)); return localVarFp.downloadArchive(albumId, name, skip, key, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@ -3800,14 +3807,15 @@ export class AlbumApi extends BaseAPI {
/** /**
* *
* @param {string} albumId * @param {string} albumId
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AlbumApi * @memberof AlbumApi
*/ */
public downloadArchive(albumId: string, skip?: number, key?: string, options?: AxiosRequestConfig) { public downloadArchive(albumId: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).downloadArchive(albumId, skip, key, options).then((request) => request(this.axios, this.basePath)); return AlbumApiFp(this.configuration).downloadArchive(albumId, name, skip, key, options).then((request) => request(this.axios, this.basePath));
} }
/** /**
@ -4195,12 +4203,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
}, },
/** /**
* Current this is not used in any UI element * Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
downloadLibrary: async (skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/download-library`; const localVarPath = `/asset/download-library`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -4219,6 +4228,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// authentication cookie required // authentication cookie required
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
if (skip !== undefined) { if (skip !== undefined) {
localVarQueryParameter['skip'] = skip; localVarQueryParameter['skip'] = skip;
} }
@ -5029,13 +5042,14 @@ export const AssetApiFp = function(configuration?: Configuration) {
}, },
/** /**
* Current this is not used in any UI element * Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async downloadLibrary(skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> { async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(skip, key, options); const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -5284,13 +5298,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
}, },
/** /**
* Current this is not used in any UI element * Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
downloadLibrary(skip?: number, key?: string, options?: any): AxiosPromise<any> { downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise<any> {
return localVarFp.downloadLibrary(skip, key, options).then((request) => request(axios, basePath)); return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath));
}, },
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
@ -5537,14 +5552,15 @@ export class AssetApi extends BaseAPI {
/** /**
* Current this is not used in any UI element * Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip] * @param {number} [skip]
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @memberof AssetApi
*/ */
public downloadLibrary(skip?: number, key?: string, options?: AxiosRequestConfig) { public downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadLibrary(skip, key, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).downloadLibrary(name, skip, key, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View file

@ -264,6 +264,7 @@
const { data, status, headers } = await api.albumApi.downloadArchive( const { data, status, headers } = await api.albumApi.downloadArchive(
album.id, album.id,
undefined,
skip || undefined, skip || undefined,
sharedLink?.key, sharedLink?.key,
{ {