mirror of
https://github.com/immich-app/immich.git
synced 2025-03-04 02:11:44 -05:00
feat(server): Per user asset access control (#993)
* Limit asset access to owner
* Check public albums for asset
* Clean up
* Fix test
* Rename repository method
* Simplify control flow
* Revert "Simplify control flow"
This reverts commit 7bc3cbf687
.
* Revert Makefile change
This commit is contained in:
parent
5f2b75997f
commit
e8bbad6772
8 changed files with 84 additions and 16 deletions
|
@ -25,6 +25,7 @@ export interface IAlbumRepository {
|
||||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||||
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
|
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||||
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
|
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
|
||||||
|
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
||||||
|
@ -283,4 +284,17 @@ export class AlbumRepository implements IAlbumRepository {
|
||||||
|
|
||||||
return this.albumRepository.save(album);
|
return this.albumRepository.save(album);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number> {
|
||||||
|
const result = await this
|
||||||
|
.userAlbumRepository
|
||||||
|
.createQueryBuilder('usa')
|
||||||
|
.select('count(aa)', 'count')
|
||||||
|
.innerJoin('asset_album', 'aa', 'aa.albumId = usa.albumId')
|
||||||
|
.where('aa.assetId = :assetId', { assetId })
|
||||||
|
.andWhere('usa.sharedUserId = :userId', { userId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -123,6 +123,7 @@ describe('Album service', () => {
|
||||||
updateAlbum: jest.fn(),
|
updateAlbum: jest.fn(),
|
||||||
getListByAssetId: jest.fn(),
|
getListByAssetId: jest.fn(),
|
||||||
getCountByUserId: jest.fn(),
|
getCountByUserId: jest.fn(),
|
||||||
|
getSharedWithUserAlbumCount: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assetRepositoryMock = {
|
assetRepositoryMock = {
|
||||||
|
@ -142,6 +143,7 @@ describe('Album service', () => {
|
||||||
getAssetWithNoThumbnail: jest.fn(),
|
getAssetWithNoThumbnail: jest.fn(),
|
||||||
getAssetWithNoSmartInfo: jest.fn(),
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
getExistingAssets: jest.fn(),
|
getExistingAssets: jest.fn(),
|
||||||
|
countByIdAndUser: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadServiceMock = {
|
downloadServiceMock = {
|
||||||
|
|
|
@ -43,6 +43,7 @@ export interface IAssetRepository {
|
||||||
userId: string,
|
userId: string,
|
||||||
checkDuplicateAssetDto: CheckExistingAssetsDto,
|
checkDuplicateAssetDto: CheckExistingAssetsDto,
|
||||||
): Promise<CheckExistingAssetsResponseDto>;
|
): Promise<CheckExistingAssetsResponseDto>;
|
||||||
|
countByIdAndUser(assetId: string, userId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
|
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
|
||||||
|
@ -343,4 +344,13 @@ export class AssetRepository implements IAssetRepository {
|
||||||
});
|
});
|
||||||
return new CheckExistingAssetsResponseDto(existingAssets.map((a) => a.deviceAssetId));
|
return new CheckExistingAssetsResponseDto(existingAssets.map((a) => a.deviceAssetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countByIdAndUser(assetId: string, userId: string): Promise<number> {
|
||||||
|
return await this.assetRepository.count({
|
||||||
|
where: {
|
||||||
|
id: assetId,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { Response as Res} from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
@ -86,10 +86,12 @@ export class AssetController {
|
||||||
|
|
||||||
@Get('/download/:assetId')
|
@Get('/download/:assetId')
|
||||||
async downloadFile(
|
async downloadFile(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
|
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||||
return this.assetService.downloadFile(query, assetId, res);
|
return this.assetService.downloadFile(query, assetId, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,22 +112,26 @@ export class AssetController {
|
||||||
@Get('/file/:assetId')
|
@Get('/file/:assetId')
|
||||||
@Header('Cache-Control', 'max-age=3600')
|
@Header('Cache-Control', 'max-age=3600')
|
||||||
async serveFile(
|
async serveFile(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Headers() headers: Record<string, string>,
|
@Headers() headers: Record<string, string>,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
|
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||||
return this.assetService.serveFile(assetId, query, res, headers);
|
return this.assetService.serveFile(assetId, query, res, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/thumbnail/:assetId')
|
@Get('/thumbnail/:assetId')
|
||||||
@Header('Cache-Control', 'max-age=3600')
|
@Header('Cache-Control', 'max-age=3600')
|
||||||
async getAssetThumbnail(
|
async getAssetThumbnail(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Headers() headers: Record<string, string>,
|
@Headers() headers: Record<string, string>,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
|
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||||
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
|
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,7 +209,8 @@ export class AssetController {
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
): Promise<AssetResponseDto> {
|
): Promise<AssetResponseDto> {
|
||||||
return await this.assetService.getAssetById(authUser, assetId);
|
await this.assetService.checkAssetsAccess(authUser, [assetId]);
|
||||||
|
return await this.assetService.getAssetById(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -215,7 +222,8 @@ export class AssetController {
|
||||||
@Param('assetId') assetId: string,
|
@Param('assetId') assetId: string,
|
||||||
@Body() dto: UpdateAssetDto,
|
@Body() dto: UpdateAssetDto,
|
||||||
): Promise<AssetResponseDto> {
|
): Promise<AssetResponseDto> {
|
||||||
return await this.assetService.updateAssetById(authUser, assetId, dto);
|
await this.assetService.checkAssetsAccess(authUser, [assetId], true);
|
||||||
|
return await this.assetService.updateAssetById(assetId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('/')
|
@Delete('/')
|
||||||
|
@ -223,17 +231,19 @@ export class AssetController {
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Body(ValidationPipe) assetIds: DeleteAssetDto,
|
@Body(ValidationPipe) assetIds: DeleteAssetDto,
|
||||||
): Promise<DeleteAssetResponseDto[]> {
|
): Promise<DeleteAssetResponseDto[]> {
|
||||||
|
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
|
||||||
|
|
||||||
const deleteAssetList: AssetResponseDto[] = [];
|
const deleteAssetList: AssetResponseDto[] = [];
|
||||||
|
|
||||||
for (const id of assetIds.ids) {
|
for (const id of assetIds.ids) {
|
||||||
const assets = await this.assetService.getAssetById(authUser, id);
|
const assets = await this.assetService.getAssetById(id);
|
||||||
if (!assets) {
|
if (!assets) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
deleteAssetList.push(assets);
|
deleteAssetList.push(assets);
|
||||||
|
|
||||||
if (assets.livePhotoVideoId) {
|
if (assets.livePhotoVideoId) {
|
||||||
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
|
const livePhotoVideo = await this.assetService.getAssetById(assets.livePhotoVideoId);
|
||||||
if (livePhotoVideo) {
|
if (livePhotoVideo) {
|
||||||
deleteAssetList.push(livePhotoVideo);
|
deleteAssetList.push(livePhotoVideo);
|
||||||
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
||||||
|
@ -241,7 +251,7 @@ export class AssetController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
const result = await this.assetService.deleteAssetById(assetIds);
|
||||||
|
|
||||||
result.forEach((res) => {
|
result.forEach((res) => {
|
||||||
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);
|
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);
|
||||||
|
|
|
@ -10,13 +10,18 @@ import { CommunicationModule } from '../communication/communication.module';
|
||||||
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||||
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
|
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
|
||||||
import { DownloadModule } from '../../modules/download/download.module';
|
import { DownloadModule } from '../../modules/download/download.module';
|
||||||
|
import { ALBUM_REPOSITORY, AlbumRepository } from '../album/album-repository';
|
||||||
|
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||||
|
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CommunicationModule,
|
CommunicationModule,
|
||||||
BackgroundTaskModule,
|
BackgroundTaskModule,
|
||||||
DownloadModule,
|
DownloadModule,
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity, AlbumEntity, UserAlbumEntity, UserEntity, AssetAlbumEntity]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueNameEnum.ASSET_UPLOADED,
|
name: QueueNameEnum.ASSET_UPLOADED,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
|
@ -42,6 +47,10 @@ import { DownloadModule } from '../../modules/download/download.module';
|
||||||
provide: ASSET_REPOSITORY,
|
provide: ASSET_REPOSITORY,
|
||||||
useClass: AssetRepository,
|
useClass: AssetRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ALBUM_REPOSITORY,
|
||||||
|
useClass: AlbumRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [AssetService],
|
exports: [AssetService],
|
||||||
})
|
})
|
||||||
|
|
|
@ -11,11 +11,13 @@ import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
|
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
|
import { IAlbumRepository } from "../album/album-repository";
|
||||||
|
|
||||||
describe('AssetService', () => {
|
describe('AssetService', () => {
|
||||||
let sui: AssetService;
|
let sui: AssetService;
|
||||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||||
|
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||||
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
||||||
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
|
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
|
||||||
|
@ -122,6 +124,7 @@ describe('AssetService', () => {
|
||||||
getAssetWithNoThumbnail: jest.fn(),
|
getAssetWithNoThumbnail: jest.fn(),
|
||||||
getAssetWithNoSmartInfo: jest.fn(),
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
getExistingAssets: jest.fn(),
|
getExistingAssets: jest.fn(),
|
||||||
|
countByIdAndUser: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadServiceMock = {
|
downloadServiceMock = {
|
||||||
|
@ -130,6 +133,7 @@ describe('AssetService', () => {
|
||||||
|
|
||||||
sui = new AssetService(
|
sui = new AssetService(
|
||||||
assetRepositoryMock,
|
assetRepositoryMock,
|
||||||
|
albumRepositoryMock,
|
||||||
a,
|
a,
|
||||||
backgroundTaskServiceMock,
|
backgroundTaskServiceMock,
|
||||||
assetUploadedQueueMock,
|
assetUploadedQueueMock,
|
||||||
|
|
|
@ -54,6 +54,7 @@ import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { DownloadService } from '../../modules/download/download.service';
|
import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { DownloadDto } from './dto/download-library.dto';
|
import { DownloadDto } from './dto/download-library.dto';
|
||||||
|
import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
|
@ -63,6 +64,9 @@ export class AssetService {
|
||||||
@Inject(ASSET_REPOSITORY)
|
@Inject(ASSET_REPOSITORY)
|
||||||
private _assetRepository: IAssetRepository,
|
private _assetRepository: IAssetRepository,
|
||||||
|
|
||||||
|
@Inject(ALBUM_REPOSITORY)
|
||||||
|
private _albumRepository: IAlbumRepository,
|
||||||
|
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
|
@ -221,22 +225,18 @@ export class AssetService {
|
||||||
return assets.map((asset) => mapAsset(asset));
|
return assets.map((asset) => mapAsset(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
|
public async getAssetById(assetId: string): Promise<AssetResponseDto> {
|
||||||
const asset = await this._assetRepository.getById(assetId);
|
const asset = await this._assetRepository.getById(assetId);
|
||||||
|
|
||||||
return mapAsset(asset);
|
return mapAsset(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAssetById(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
public async updateAssetById(assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||||
const asset = await this._assetRepository.getById(assetId);
|
const asset = await this._assetRepository.getById(assetId);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new BadRequestException('Asset not found');
|
throw new BadRequestException('Asset not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser.id !== asset.userId) {
|
|
||||||
throw new ForbiddenException('Not the owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAsset = await this._assetRepository.update(asset, dto);
|
const updatedAsset = await this._assetRepository.update(asset, dto);
|
||||||
|
|
||||||
return mapAsset(updatedAsset);
|
return mapAsset(updatedAsset);
|
||||||
|
@ -496,14 +496,13 @@ export class AssetService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||||
const result: DeleteAssetResponseDto[] = [];
|
const result: DeleteAssetResponseDto[] = [];
|
||||||
|
|
||||||
const target = assetIds.ids;
|
const target = assetIds.ids;
|
||||||
for (const assetId of target) {
|
for (const assetId of target) {
|
||||||
const res = await this.assetRepository.delete({
|
const res = await this.assetRepository.delete({
|
||||||
id: assetId,
|
id: assetId,
|
||||||
userId: authUser.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.affected) {
|
if (res.affected) {
|
||||||
|
@ -642,6 +641,26 @@ export class AssetService {
|
||||||
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||||
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
// Step 1: Check if user owns asset
|
||||||
|
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid additional checks if ownership is required
|
||||||
|
if (!mustBeOwner) {
|
||||||
|
// Step 2: Check if asset is part of an album shared with me
|
||||||
|
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Step 3: Check if asset is part of a public album
|
||||||
|
}
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
|
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
|
||||||
|
|
Loading…
Add table
Reference in a new issue