Merge branch 'main' of https://github.com/immich-app/immich into feat/xxhash

This commit is contained in:
Jonathan Jogenfors
2024-10-11 13:46:17 +02:00
120 changed files with 3209 additions and 2753 deletions

View File

@@ -15,9 +15,6 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { getAssetFiles } from 'src/utils/asset.util';
import { getConfig } from 'src/utils/config';
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));
export interface MoveRequest {
entityId: string;
pathType: PathType;
@@ -118,10 +115,6 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation);
}
static isGeneratedAsset(path: string) {
return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR);
}
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) {
const { id: entityId, files } = asset;
const { thumbnailFile, previewFile } = getAssetFiles(files);

View File

@@ -36,7 +36,7 @@ export class AssetFileEntity {
@Column()
path!: string;
@Column({ type: 'bigint' })
@Column({ type: 'bytea' })
@Index()
checksum!: BigInt | null;
checksum!: Buffer | null;
}

View File

@@ -145,7 +145,7 @@ export interface UpsertFileOptions {
assetId: string;
type: AssetFileType;
path: string;
checksum?: BigInt;
checksum?: Buffer;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
@@ -173,12 +173,6 @@ export interface IAssetRepository {
order?: FindOptionsOrder<AssetEntity>,
): Promise<AssetEntity | null>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(
pagination: PaginationOptions,
property: WithProperty,
libraryId?: string,
withDeleted?: boolean,
): Paginated<AssetEntity>;
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;

View File

@@ -5,7 +5,7 @@ export interface ICryptoRepository {
randomUUID(): string;
hashFile(filePath: string | Buffer): Promise<Buffer>;
hashSha256(data: string): string;
xxHash(value: string): BigInt;
xxHash(value: string): Buffer;
verifySha256(data: string, encrypted: string, publicKey: string): boolean;
hashSha1(data: string | Buffer): Buffer;
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;

View File

@@ -48,7 +48,6 @@ export interface IDatabaseRepository {
getPostgresVersion(): Promise<string>;
getPostgresVersionRange(): string;
createExtension(extension: DatabaseExtension): Promise<void>;
updateExtension(extension: DatabaseExtension, version?: string): Promise<void>;
updateVectorExtension(extension: VectorExtension, version?: string): Promise<VectorUpdateResult>;
reindex(index: VectorIndex): Promise<void>;
shouldReindex(name: VectorIndex): Promise<boolean>;

View File

@@ -28,5 +28,4 @@ export interface IMapRepository {
init(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
fetchStyle(url: string): Promise<any>;
}

View File

@@ -57,9 +57,7 @@ export interface IPersonRepository {
create(person: Partial<PersonEntity>): Promise<PersonEntity>;
createAll(people: Partial<PersonEntity>[]): Promise<string[]>;
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
delete(entities: PersonEntity[]): Promise<void>;
deleteAll(): Promise<void>;
deleteFaces(options: DeleteFacesOptions): Promise<void>;
refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[],

View File

@@ -34,7 +34,7 @@ export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */
uuid: string;
checksum: Buffer;
xxhash: BigInt;
xxhash: Buffer;
}
export function mapToUploadFile(file: ImmichFile): UploadFile {
@@ -150,7 +150,7 @@ export class FileUploadInterceptor implements NestInterceptor {
}
this.logger.debug(`Handling asset upload file: ${file.originalname}`);
const xxhash = new xxh3.Xxh3();
const xxhash = xxh3.Xxh3.withSeed();
const sha1hash = createHash('sha1');
file.stream.on('data', (chunk) => {
@@ -164,7 +164,11 @@ export class FileUploadInterceptor implements NestInterceptor {
xxhash.reset();
callback(error);
} else {
callback(null, { ...info, checksum: sha1hash.digest(), xxhash: xxhash.digest() });
callback(null, {
...info,
checksum: sha1hash.digest(),
xxhash: Buffer.from(xxhash.digest().toString(16), 'utf8'),
});
}
});
}

View File

@@ -4,8 +4,8 @@ export class AssetFileChecksum1728632095015 implements MigrationInterface {
name = 'AssetFileChecksum1728632095015';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_files" ADD "checksum" bigint`);
await queryRunner.query(`CREATE INDEX "IDX_c946066edd16cfa5c25a26aa8e" ON "asset_files" ("checksum") `);
await queryRunner.query(`ALTER TABLE "asset_files" ADD "checksum" bytea NULL`);
await queryRunner.query(`CREATE INDEX "IDX_c946066edd16cfa5c25a26aa8e" ON "asset_files" ("checksum")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {

View File

@@ -499,39 +499,6 @@ export class AssetRepository implements IAssetRepository {
});
}
getWith(
pagination: PaginationOptions,
property: WithProperty,
libraryId?: string,
withDeleted = false,
): Paginated<AssetEntity> {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithProperty.SIDECAR: {
where = [{ sidecarPath: Not(IsNull()), isVisible: true }];
break;
}
default: {
throw new Error(`Invalid getWith property: ${property}`);
}
}
if (libraryId) {
where = [{ ...where, libraryId }];
}
return paginate(this.repository, pagination, {
where,
withDeleted,
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
},
});
}
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
@@ -801,7 +768,7 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
async upsertFile(file: { assetId: string; type: AssetFileType; path: string; checksum?: BigInt }): Promise<void> {
async upsertFile(file: { assetId: string; type: AssetFileType; path: string; checksum?: Buffer }): Promise<void> {
await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] });
}

View File

@@ -30,7 +30,7 @@ export class CryptoRepository implements ICryptoRepository {
}
xxHash(value: string) {
return xxh3.Xxh3.withSeed().update(value).digest();
return Buffer.from(xxh3.Xxh3.withSeed().update(value).digest().toString(16), 'utf8');
}
verifySha256(value: string, encryptedValue: string, publicKey: string) {

View File

@@ -74,10 +74,6 @@ export class DatabaseRepository implements IDatabaseRepository {
await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`);
}
async updateExtension(extension: DatabaseExtension, version?: string): Promise<void> {
await this.dataSource.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`);
}
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> {
const { availableVersion, installedVersion } = await this.getExtensionVersion(extension);
if (!installedVersion) {

View File

@@ -113,20 +113,6 @@ export class MapRepository implements IMapRepository {
}));
}
async fetchStyle(url: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error(`Failed to fetch data from ${url}: ${error}`);
}
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);

View File

@@ -63,10 +63,6 @@ export class PersonRepository implements IPersonRepository {
await this.personRepository.remove(entities);
}
async deleteAll(): Promise<void> {
await this.personRepository.clear();
}
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.assetFaceRepository
.createQueryBuilder('asset_faces')
@@ -269,11 +265,6 @@ export class PersonRepository implements IPersonRepository {
return results.map((person) => person.id);
}
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
const res = await this.assetFaceRepository.save(entities);
return res.map((row) => row.id);
}
async refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[],
faceIdsToRemove: string[],

View File

@@ -14,12 +14,11 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { ActivityEntity } from 'src/entities/activity.entity';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class ActivityService extends BaseService {
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
const activities = await this.activityRepository.search({
userId: dto.userId,
albumId: dto.albumId,
@@ -31,12 +30,12 @@ export class ActivityService extends BaseService {
}
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) };
}
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] });
await this.requireAccess({ auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] });
const common = {
userId: auth.user.id,
@@ -70,7 +69,7 @@ export class ActivityService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ACTIVITY_DELETE, ids: [id] });
await this.activityRepository.delete(id);
}
}

View File

@@ -19,7 +19,6 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
@@ -82,7 +81,7 @@ export class AlbumService extends BaseService {
}
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [id] });
await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
@@ -106,7 +105,7 @@ export class AlbumService extends BaseService {
}
}
const allowedAssetIdsSet = await checkAccess(this.accessRepository, {
const allowedAssetIdsSet = await this.checkAccess({
auth,
permission: Permission.ASSET_SHARE,
ids: dto.assetIds || [],
@@ -130,7 +129,7 @@ export class AlbumService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
@@ -153,13 +152,13 @@ export class AlbumService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await this.albumRepository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
const results = await addAssets(
auth,
@@ -182,7 +181,7 @@ export class AlbumService extends BaseService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const results = await removeAssets(
@@ -203,7 +202,7 @@ export class AlbumService extends BaseService {
}
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
@@ -247,14 +246,14 @@ export class AlbumService extends BaseService {
// non-admin can remove themselves
if (auth.user.id !== userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
}
await this.albumUserRepository.delete({ albumId: id, userId });
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}

View File

@@ -539,6 +539,7 @@ describe(AssetMediaService.name, () => {
path: '/path/to/preview',
type: AssetFileType.THUMBNAIL,
updatedAt: new Date(),
checksum: null,
},
],
});
@@ -559,6 +560,7 @@ describe(AssetMediaService.name, () => {
path: '/path/to/preview.jpg',
type: AssetFileType.PREVIEW,
updatedAt: new Date(),
checksum: null,
},
],
});

View File

@@ -24,7 +24,7 @@ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entit
import { AssetFileType, AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { requireUploadAccess } from 'src/utils/access';
import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
@@ -39,7 +39,7 @@ export interface UploadRequest {
export interface UploadFile {
uuid: string;
checksum: Buffer;
xxhash: BigInt;
xxhash?: Buffer;
originalPath: string;
originalName: string;
size: number;
@@ -126,7 +126,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await requireAccess(this.accessRepository, {
await this.requireAccess({
auth,
permission: Permission.ASSET_UPLOAD,
// do not need an id here, but the interface requires it
@@ -160,7 +160,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const asset = (await this.assetRepository.getById(id)) as AssetEntity;
this.requireQuota(auth, file.size);
@@ -183,7 +183,7 @@ export class AssetMediaService extends BaseService {
}
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });
const asset = await this.findOrFail(id);
@@ -195,7 +195,7 @@ export class AssetMediaService extends BaseService {
}
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
@@ -218,7 +218,7 @@ export class AssetMediaService extends BaseService {
}
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id);
@@ -342,8 +342,6 @@ export class AssetMediaService extends BaseService {
checksum: file.xxhash,
});
console.log('xxhash', file.xxhash);
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
await this.jobRepository.queue({

View File

@@ -29,7 +29,6 @@ import {
JobStatus,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@@ -86,7 +85,7 @@ export class AssetService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] });
const asset = await this.assetRepository.getById(
id,
@@ -135,7 +134,7 @@ export class AssetService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository };
@@ -178,7 +177,7 @@ export class AssetService extends BaseService {
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids });
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
@@ -275,7 +274,7 @@ export class AssetService extends BaseService {
async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> {
const { ids, force } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids });
await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids });
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
@@ -284,7 +283,7 @@ export class AssetService extends BaseService {
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const jobs: JobItem[] = [];

View File

@@ -23,7 +23,6 @@ import {
} from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@@ -36,7 +35,7 @@ export class AuditService extends BaseService {
async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const audits = await this.auditRepository.getAfter(dto.after, {
userIds: [userId],

View File

@@ -13,7 +13,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub, loginResponseStub } from 'test/fixtures/auth.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
@@ -21,6 +21,16 @@ import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { Mocked } from 'vitest';
const oauthResponse = {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id',
userEmail: 'immich@test.com',
name: 'immich_name',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
};
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
const email = 'test@immich.com';
@@ -100,7 +110,15 @@ describe('AuthService', () => {
it('should successfully log the user in', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id',
userEmail: 'immich@test.com',
name: 'immich_name',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
});
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
});
@@ -469,7 +487,7 @@ describe('AuthService', () => {
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
@@ -498,7 +516,7 @@ describe('AuthService', () => {
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
@@ -546,7 +564,7 @@ describe('AuthService', () => {
userMock.create.mockResolvedValue(userStub.user1);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota);
@@ -560,7 +578,7 @@ describe('AuthService', () => {
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota);
@@ -574,7 +592,7 @@ describe('AuthService', () => {
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota);
@@ -588,7 +606,7 @@ describe('AuthService', () => {
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.create).toHaveBeenCalledWith({
@@ -608,7 +626,7 @@ describe('AuthService', () => {
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
oauthResponse,
);
expect(userMock.create).toHaveBeenCalledWith({

View File

@@ -38,6 +38,7 @@ import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
export class BaseService {
@@ -95,7 +96,7 @@ export class BaseService {
);
}
private get repos() {
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
@@ -104,10 +105,18 @@ export class BaseService {
}
getConfig(options: { withCache: boolean }) {
return getConfig(this.repos, options);
return getConfig(this.configRepos, options);
}
updateConfig(newConfig: SystemConfig) {
return updateConfig(this.repos, newConfig);
return updateConfig(this.configRepos, newConfig);
}
requireAccess(request: AccessRequest) {
return requireAccess(this.accessRepository, request);
}
checkAccess(request: AccessRequest) {
return checkAccess(this.accessRepository, request);
}
}

View File

@@ -1,3 +1,4 @@
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub';
@@ -8,9 +9,18 @@ describe(CliService.name, () => {
let sut: CliService;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
({ sut, userMock } = newTestService(CliService));
({ sut, userMock, systemMock } = newTestService(CliService));
});
describe('listUsers', () => {
it('should list users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true });
});
});
describe('resetAdminPassword', () => {
@@ -51,4 +61,32 @@ describe(CliService.name, () => {
expect(update.password).toBeDefined();
});
});
describe('disablePasswordLogin', () => {
it('should disable password login', async () => {
await sut.disablePasswordLogin();
expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } });
});
});
describe('enablePasswordLogin', () => {
it('should enable password login', async () => {
await sut.enablePasswordLogin();
expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
});
});
describe('disableOAuthLogin', () => {
it('should disable oauth login', async () => {
await sut.disableOAuthLogin();
expect(systemMock.set).toHaveBeenCalledWith('system-config', {});
});
});
describe('enableOAuthLogin', () => {
it('should enable oauth login', async () => {
await sut.enableOAuthLogin();
expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } });
});
});
});

View File

@@ -43,19 +43,246 @@ describe(DatabaseService.name, () => {
expect(sut).toBeDefined();
});
it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0');
describe('onBootstrap', () => {
it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0');
await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
});
expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
});
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: false,
vectorExtension: extension,
},
}),
);
});
it(`should start up successfully with ${extension}`, async () => {
databaseMock.getPostgresVersion.mockResolvedValue('14.0.0');
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw an error if the ${extension} extension is not installed`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
const message = `The ${extensionName} extension is not available in this Postgres instance.
If using a container image, ensure the image has the extension installed.`;
await expect(sut.onBootstrap()).rejects.toThrow(message);
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: versionBelowRange,
availableVersion: versionBelowRange,
});
await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`,
);
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw an error if ${extension} extension version is a nightly`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`,
);
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should do in-range update for ${extension} extension`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should not upgrade ${extension} if same version`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange,
installedVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw error if ${extension} available version is below range`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionBelowRange,
installedVersion: null,
});
await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw error if ${extension} available version is above range`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionAboveRange,
installedVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it('should throw error if available version is below installed version', async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange,
installedVersion: updateInRange,
});
await expect(sut.onBootstrap()).rejects.toThrow(
`The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`,
);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it('should throw error if installed version is not in version range', async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange,
installedVersion: versionAboveRange,
});
await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`,
);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should raise error if ${extension} extension upgrade failed`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
expect(loggerMock.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`,
);
expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should warn if ${extension} extension update requires restart`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true });
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should reindex ${extension} indices if needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(true);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw an error if reindexing fails`, async () => {
databaseMock.shouldReindex.mockResolvedValue(true);
databaseMock.reindex.mockRejectedValue(new Error('Error reindexing'));
await expect(sut.onBootstrap()).rejects.toBeDefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1);
expect(databaseMock.reindex).toHaveBeenCalledTimes(1);
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not run vector reindexing checks.'),
);
});
it(`should not reindex ${extension} indices if not needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(false);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
@@ -64,261 +291,112 @@ describe(DatabaseService.name, () => {
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: false,
vectorExtension: extension,
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
},
}),
);
});
it(`should start up successfully with ${extension}`, async () => {
databaseMock.getPostgresVersion.mockResolvedValue('14.0.0');
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw an error if the ${extension} extension is not installed`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
const message = `The ${extensionName} extension is not available in this Postgres instance.
If using a container image, ensure the image has the extension installed.`;
await expect(sut.onBootstrap()).rejects.toThrow(message);
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: versionBelowRange,
availableVersion: versionBelowRange,
});
await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`,
it(`should throw error if pgvector extension could not be created`, async () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
},
}),
);
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw an error if ${extension} extension version is a nightly`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
await expect(sut.onBootstrap()).rejects.toThrow(
`The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`,
);
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should do in-range update for ${extension} extension`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
installedVersion: null,
availableVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.getExtensionVersion).toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should not upgrade ${extension} if same version`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange,
installedVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw error if ${extension} available version is below range`, async () => {
it(`should throw error if pgvecto.rs extension could not be created`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionBelowRange,
installedVersion: null,
});
await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should throw error if ${extension} available version is above range`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: versionAboveRange,
installedVersion: minVersionInRange,
});
await expect(sut.onBootstrap()).rejects.toThrow();
expect(databaseMock.createExtension).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it('should throw error if available version is below installed version', async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: minVersionInRange,
installedVersion: updateInRange,
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow(
`The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`,
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvector, you may use this instead`,
);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should raise error if ${extension} extension upgrade failed`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
expect(loggerMock.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`,
);
expect(loggerMock.fatal).not.toHaveBeenCalled();
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should warn if ${extension} extension update requires restart`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
availableVersion: updateInRange,
installedVersion: minVersionInRange,
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true });
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should reindex ${extension} indices if needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(true);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
it(`should not reindex ${extension} indices if not needed`, async () => {
databaseMock.shouldReindex.mockResolvedValue(false);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal).not.toHaveBeenCalled();
});
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
},
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw error if pgvector extension could not be created`, async () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
},
}),
);
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,
describe('handleConnectionError', () => {
beforeAll(() => {
vi.useFakeTimers();
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
});
it(`should throw error if pgvecto.rs extension could not be created`, async () => {
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,
afterAll(() => {
vi.useRealTimers();
});
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
it('should not override interval', () => {
sut.handleConnectionError(new Error('Error'));
expect(loggerMock.error).toHaveBeenCalled();
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
`Alternatively, if your Postgres instance has pgvector, you may use this instead`,
);
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
sut.handleConnectionError(new Error('foo'));
expect(loggerMock.error).toHaveBeenCalledTimes(1);
});
it('should reconnect when interval elapses', async () => {
databaseMock.reconnect.mockResolvedValue(true);
sut.handleConnectionError(new Error('error'));
await vi.advanceTimersByTimeAsync(5000);
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
await vi.advanceTimersByTimeAsync(5000);
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
});
it('should try again when reconnection fails', async () => {
databaseMock.reconnect.mockResolvedValueOnce(false);
sut.handleConnectionError(new Error('error'));
await vi.advanceTimersByTimeAsync(5000);
expect(databaseMock.reconnect).toHaveBeenCalledTimes(1);
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed'));
databaseMock.reconnect.mockResolvedValueOnce(true);
await vi.advanceTimersByTimeAsync(5000);
expect(databaseMock.reconnect).toHaveBeenCalledTimes(2);
expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected');
});
});
});

View File

@@ -8,7 +8,6 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { ImmichReadStream } from 'src/interfaces/storage.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
import { usePagination } from 'src/utils/pagination';
import { getPreferences } from 'src/utils/preferences';
@@ -62,7 +61,7 @@ export class DownloadService extends BaseService {
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds);
@@ -105,20 +104,20 @@ export class DownloadService extends BaseService {
if (dto.assetIds) {
const assetIds = dto.assetIds;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true });
return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
}
if (dto.albumId) {
const albumId = dto.albumId;
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
}
if (dto.userId) {
const userId = dto.userId;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
return usePagination(PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
);

View File

@@ -141,8 +141,6 @@ describe(LibraryService.name, () => {
describe('handleQueueAssetRefresh', () => {
it('should queue refresh of a new asset', async () => {
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.walk.mockImplementation(mockWalk);
@@ -179,8 +177,6 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(true);
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id });

View File

@@ -341,7 +341,10 @@ export class LibraryService extends BaseService {
this.logger.debug(`Will delete all assets in library ${libraryId}`);
for await (const assets of assetPagination) {
assetsFound = true;
if (assets.length > 0) {
assetsFound = true;
}
this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`);
await this.jobRepository.queueAll(
assets.map((asset) => ({
@@ -544,32 +547,32 @@ export class LibraryService extends BaseService {
}
}
if (validImportPaths) {
const assetsOnDisk = this.storageRepository.walk({
pathsToCrawl: validImportPaths,
includeHidden: false,
exclusionPatterns: library.exclusionPatterns,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
let count = 0;
for await (const assetBatch of assetsOnDisk) {
count += assetBatch.length;
this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`);
await this.syncFiles(library, assetBatch);
this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`);
}
if (count > 0) {
this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`);
} else {
this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`);
}
} else {
if (validImportPaths.length === 0) {
this.logger.warn(`No valid import paths found for library ${library.id}`);
}
const assetsOnDisk = this.storageRepository.walk({
pathsToCrawl: validImportPaths,
includeHidden: false,
exclusionPatterns: library.exclusionPatterns,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
let count = 0;
for await (const assetBatch of assetsOnDisk) {
count += assetBatch.length;
this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`);
await this.syncFiles(library, assetBatch);
this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`);
}
if (count > 0) {
this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`);
} else if (validImportPaths.length > 0) {
this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`);
}
await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() });
return JobStatus.SUCCESS;

View File

@@ -5,7 +5,6 @@ import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from '
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
@@ -16,7 +15,7 @@ export class MemoryService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] });
const memory = await this.findOrFail(id);
return mapMemory(memory);
}
@@ -25,7 +24,7 @@ export class MemoryService extends BaseService {
// TODO validate type/data combination
const assetIds = dto.assetIds || [];
const allowedAssetIds = await checkAccess(this.accessRepository, {
const allowedAssetIds = await this.checkAccess({
auth,
permission: Permission.ASSET_SHARE,
ids: assetIds,
@@ -44,7 +43,7 @@ export class MemoryService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const memory = await this.memoryRepository.update({
id,
@@ -57,12 +56,12 @@ export class MemoryService extends BaseService {
}
async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.MEMORY_DELETE, ids: [id] });
await this.memoryRepository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
@@ -76,7 +75,7 @@ export class MemoryService extends BaseService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const results = await removeAssets(auth, repos, {

View File

@@ -6,7 +6,6 @@ import { PartnerEntity } from 'src/entities/partner.entity';
import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class PartnerService extends BaseService {
@@ -41,7 +40,7 @@ export class PartnerService extends BaseService {
}
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] });
await this.requireAccess({ auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] });
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline });

View File

@@ -721,7 +721,6 @@ describe(PersonService.name, () => {
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
);
expect(personMock.createFaces).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
@@ -733,7 +732,6 @@ describe(PersonService.name, () => {
});
it('should create a face with no person and queue recognition job', async () => {
personMock.createFaces.mockResolvedValue([faceStub.face1.id]);
machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
@@ -761,7 +759,6 @@ describe(PersonService.name, () => {
});
it('should add new face and delete an existing face not among the new detected faces', async () => {
personMock.createFaces.mockResolvedValue([faceStub.face1.id]);
machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock);
assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]);
@@ -816,7 +813,6 @@ describe(PersonService.name, () => {
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled();
expect(personMock.createFaces).not.toHaveBeenCalled();
});
it('should fail if face does not have asset', async () => {
@@ -827,7 +823,6 @@ describe(PersonService.name, () => {
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled();
expect(personMock.createFaces).not.toHaveBeenCalled();
});
it('should skip if face already has an assigned person', async () => {
@@ -837,7 +832,6 @@ describe(PersonService.name, () => {
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled();
expect(personMock.createFaces).not.toHaveBeenCalled();
});
it('should match existing person', async () => {

View File

@@ -47,7 +47,6 @@ import { BoundingBox } from 'src/interfaces/machine-learning.interface';
import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface';
import { UpdateFacesData } from 'src/interfaces/person.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
@@ -80,7 +79,7 @@ export class PersonService extends BaseService {
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
@@ -88,7 +87,7 @@ export class PersonService extends BaseService {
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
}
@@ -109,8 +108,8 @@ export class PersonService extends BaseService {
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
@@ -126,7 +125,7 @@ export class PersonService extends BaseService {
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [dto.id] });
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, auth));
}
@@ -150,17 +149,17 @@ export class PersonService extends BaseService {
}
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
return this.findOrFail(id).then(mapPerson);
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
return this.personRepository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
const person = await this.personRepository.getById(id);
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
@@ -183,13 +182,13 @@ export class PersonService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [assetId] });
await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [assetId] });
const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]);
if (!face) {
throw new BadRequestException('Invalid assetId for feature face');
@@ -584,13 +583,13 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot merge a person into themselves');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = [];
const allowedIds = await checkAccess(this.accessRepository, {
const allowedIds = await this.checkAccess({
auth,
permission: Permission.PERSON_MERGE,
ids: mergeIds,

View File

@@ -5,7 +5,6 @@ import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
import { JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class SessionService extends BaseService {
@@ -34,7 +33,7 @@ export class SessionService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] });
await this.sessionRepository.delete(id);
}

View File

@@ -15,7 +15,6 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { OpenGraphTags } from 'src/utils/misc';
@Injectable()
@@ -49,7 +48,7 @@ export class SharedLinkService extends BaseService {
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] });
await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] });
break;
}
@@ -58,7 +57,7 @@ export class SharedLinkService extends BaseService {
throw new BadRequestException('Invalid assetIds');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds });
await this.requireAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds });
break;
}
@@ -119,7 +118,7 @@ export class SharedLinkService extends BaseService {
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
const allowedAssetIds = await checkAccess(this.accessRepository, {
const allowedAssetIds = await this.checkAccess({
auth,
permission: Permission.ASSET_SHARE,
ids: notPresentAssetIds,

View File

@@ -4,7 +4,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class StackService extends BaseService {
@@ -18,7 +17,7 @@ export class StackService extends BaseService {
}
async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
@@ -28,13 +27,13 @@ export class StackService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.STACK_READ, ids: [id] });
const stack = await this.findOrFail(id);
return mapStack(stack, { auth });
}
async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.STACK_UPDATE, ids: [id] });
const stack = await this.findOrFail(id);
if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) {
throw new BadRequestException('Primary asset must be in the stack');
@@ -48,13 +47,13 @@ export class StackService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: [id] });
await this.stackRepository.delete(id);
await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id });
}
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: dto.ids });
await this.stackRepository.deleteAll(dto.ids);
await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id });
}

View File

@@ -5,7 +5,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { setIsEqual } from 'src/utils/set';
@@ -15,7 +14,7 @@ export class SyncService extends BaseService {
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId,
updatedUntil: dto.updatedUntil,
@@ -39,7 +38,7 @@ export class SyncService extends BaseService {
return FULL_SYNC;
}
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds });
await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: dto.userIds });
const limit = 10_000;
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });

View File

@@ -15,7 +15,6 @@ import { Permission } from 'src/enum';
import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem } from 'src/interfaces/tag.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag';
@@ -27,7 +26,7 @@ export class TagService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [id] });
await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id);
return mapTag(tag);
}
@@ -35,7 +34,7 @@ export class TagService extends BaseService {
async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined;
if (dto.parentId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.tagRepository.get(dto.parentId)) || undefined;
if (!parent) {
throw new BadRequestException('Tag not found');
@@ -55,7 +54,7 @@ export class TagService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto;
const tag = await this.tagRepository.update({ id, color });
@@ -68,7 +67,7 @@ export class TagService extends BaseService {
}
async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_DELETE, ids: [id] });
await this.requireAccess({ auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets
@@ -77,8 +76,8 @@ export class TagService extends BaseService {
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([
checkAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
this.checkAccess({ auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]);
const items: AssetTagItem[] = [];
@@ -97,7 +96,7 @@ export class TagService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] });
await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await addAssets(
auth,
@@ -115,7 +114,7 @@ export class TagService extends BaseService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] });
await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await removeAssets(
auth,

View File

@@ -5,7 +5,6 @@ import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dt
import { Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/interfaces/asset.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
export class TimelineService extends BaseService {
@@ -48,20 +47,20 @@ export class TimelineService extends BaseService {
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
if (dto.isArchived !== false) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
}
}
if (dto.tagId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
}
if (dto.withPartners) {

View File

@@ -5,7 +5,6 @@ import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { usePagination } from 'src/utils/pagination';
export class TrashService extends BaseService {
@@ -15,7 +14,7 @@ export class TrashService extends BaseService {
return { count: 0 };
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids });
await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids });
await this.trashRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });