Merge branch 'main' into feat/search-filter-album/web

This commit is contained in:
CJPeckover
2025-10-08 23:36:08 -04:00
2033 changed files with 182993 additions and 125013 deletions
+2
View File
@@ -19,6 +19,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service';
@@ -55,6 +56,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
private jobService: JobService,
private telemetryRepository: TelemetryRepository,
private authService: AuthService,
private userRepository: UserRepository,
) {
logger.setAppName(this.worker);
}
+2 -25
View File
@@ -98,7 +98,7 @@ const create = (path: string, up: string[], down: string[]) => {
const folder = dirname(path);
const fullPath = join(folder, filename);
mkdirSync(folder, { recursive: true });
writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down }));
writeFileSync(fullPath, asMigration({ up, down }));
console.log(`Wrote ${fullPath}`);
};
@@ -128,34 +128,11 @@ const compare = async () => {
};
type MigrationProps = {
name: string;
timestamp: number;
up: string[];
down: string[];
};
const asMigration = (type: 'kysely' | 'typeorm', options: MigrationProps) =>
type === 'typeorm' ? asTypeOrmMigration(options) : asKyselyMigration(options);
const asTypeOrmMigration = ({ timestamp, name, up, down }: MigrationProps) => {
const upSql = up.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n');
const downSql = down.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n');
return `import { MigrationInterface, QueryRunner } from 'typeorm';
export class ${name}${timestamp} implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
${upSql}
}
public async down(queryRunner: QueryRunner): Promise<void> {
${downSql}
}
}
`;
};
const asKyselyMigration = ({ up, down }: MigrationProps) => {
const asMigration = ({ up, down }: MigrationProps) => {
const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
+2 -1
View File
@@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { AuthService } from 'src/services/auth.service';
import { getKyselyConfig } from 'src/utils/database';
@@ -57,7 +58,7 @@ class SqlGenerator {
try {
await this.setup();
for (const Repository of repositories) {
if (Repository === LoggingRepository) {
if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
continue;
}
await this.process(Repository);
@@ -61,7 +61,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
immich-server:
...
volumes:
- \${UPLOAD_LOCATION}:/usr/src/app/upload
- \${UPLOAD_LOCATION}:/data
...
)`;
+13 -1
View File
@@ -54,6 +54,11 @@ export interface SystemConfig {
machineLearning: {
enabled: boolean;
urls: string[];
availabilityChecks: {
enabled: boolean;
timeout: number;
interval: number;
};
clip: {
enabled: boolean;
modelName: string;
@@ -176,6 +181,8 @@ export interface SystemConfig {
};
}
export type MachineLearningConfig = SystemConfig['machineLearning'];
export const defaults = Object.freeze<SystemConfig>({
backup: {
database: {
@@ -191,7 +198,7 @@ export const defaults = Object.freeze<SystemConfig>({
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
targetAudioCodec: AudioCodec.Aac,
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le],
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
targetResolution: '720',
maxBitrate: '0',
@@ -227,6 +234,11 @@ export const defaults = Object.freeze<SystemConfig>({
machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
interval: 30_000,
},
clip: {
enabled: true,
modelName: 'ViT-B-32__openai',
+7 -9
View File
@@ -1,10 +1,11 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.5';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.6';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
@@ -41,20 +42,17 @@ export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker';
const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || '/usr/src/app/upload';
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
);
export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000;
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0';
@@ -46,8 +46,8 @@ export class ActivityController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ActivityDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -84,6 +84,13 @@ describe(AlbumController.name, () => {
});
});
describe('PUT /albums/assets', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/albums/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PATCH /albums/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
+18 -7
View File
@@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
@@ -68,12 +70,13 @@ export class AlbumController {
@Delete(':id')
@Authenticated({ permission: Permission.AlbumDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@Put(':id/assets')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
addAssetsToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -82,8 +85,14 @@ export class AlbumController {
return this.service.addAssets(auth, id, dto);
}
@Put('assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
return this.service.addAssetsToAlbums(auth, dto);
}
@Delete(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.AlbumAssetDelete })
removeAssetFromAlbum(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -93,7 +102,7 @@ export class AlbumController {
}
@Put(':id/users')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserCreate })
addUsersToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -103,7 +112,8 @@ export class AlbumController {
}
@Put(':id/user/:userId')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -114,12 +124,13 @@ export class AlbumController {
}
@Delete(':id/user/:userId')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}
@@ -1,16 +1,16 @@
import { APIKeyController } from 'src/controllers/api-key.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(APIKeyController.name, () => {
describe(ApiKeyController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(ApiKeyService);
beforeAll(async () => {
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]);
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
return () => ctx.close();
});
@@ -33,6 +33,13 @@ describe(APIKeyController.name, () => {
});
});
describe('GET /api-keys/me', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/me`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /api-keys/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
+8 -2
View File
@@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys')
@Controller('api-keys')
export class APIKeyController {
export class ApiKeyController {
constructor(private service: ApiKeyService) {}
@Post()
@@ -24,6 +24,12 @@ export class APIKeyController {
return this.service.getAll(auth);
}
@Get('me')
@Authenticated({ permission: false })
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
@@ -41,8 +47,8 @@ export class APIKeyController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -1,4 +1,6 @@
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import request from 'supertest';
@@ -11,7 +13,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'testing',
isFavorite: 'false',
duration: '0:00:00.000000',
};
@@ -27,16 +29,20 @@ describe(AssetMediaController.name, () => {
let ctx: ControllerContext;
const assetData = Buffer.from('123');
const filename = 'example.png';
const service = mockBaseService(AssetMediaService);
beforeAll(async () => {
ctx = await controllerSetup(AssetMediaController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) },
{ provide: AssetMediaService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
ctx.reset();
});
@@ -46,13 +52,61 @@ describe(AssetMediaController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should accept metadata', async () => {
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
const { status } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([mobileMetadata]),
});
expect(service.uploadAsset).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ metadata: [mobileMetadata] }),
expect.objectContaining({ originalName: 'example.png' }),
undefined,
);
expect(status).toBe(200);
});
it('should handle invalid metadata json', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: 'not-a-string-string',
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
);
});
it('should require `deviceId`', async () => {
@@ -61,7 +115,7 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
});
it('should require `fileCreatedAt`', async () => {
@@ -70,25 +124,20 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
);
});
it('should require `fileModifiedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) });
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
});
it('should require `duration`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'duration' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
);
});
it('should throw if `isFavorite` is not a boolean', async () => {
@@ -97,16 +146,18 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
});
it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
);
});
// TODO figure out how to deal with `sendFile`
@@ -34,7 +34,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichHeader, RouteKey } from 'src/enum';
import { ImmichHeader, Permission, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
@@ -61,7 +61,7 @@ export class AssetMediaController {
required: false,
})
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
async uploadAsset(
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@@ -80,7 +80,7 @@ export class AssetMediaController {
@Get(':id/original')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -96,12 +96,13 @@ export class AssetMediaController {
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@EndpointLifecycle({ addedAt: 'v1.106.0' })
@ApiOperation({
@EndpointLifecycle({
addedAt: 'v1.106.0',
deprecatedAt: 'v1.142.0',
summary: 'replaceAsset',
description: 'Replace the asset with new file, without changing its id',
})
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -120,7 +121,7 @@ export class AssetMediaController {
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
async viewAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -157,7 +158,7 @@ export class AssetMediaController {
@Get(':id/video/playback')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
async playAssetVideo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -171,12 +172,12 @@ export class AssetMediaController {
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@HttpCode(HttpStatus.OK)
@Authenticated()
@ApiOperation({
summary: 'checkExistingAssets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
@@ -188,12 +189,12 @@ export class AssetMediaController {
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetUpload })
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,
+120 -1
View File
@@ -1,4 +1,5 @@
import { AssetController } from 'src/controllers/asset.controller';
import { AssetMetadataKey } from 'src/enum';
import { AssetService } from 'src/services/asset.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
@@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
describe(AssetController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(AssetService);
beforeAll(async () => {
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
ctx.reset();
service.resetAllMocks();
});
describe('PUT /assets', () => {
@@ -115,4 +118,120 @@ describe(AssetController.name, () => {
);
});
});
describe('GET /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
});
it('should require each item to have a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'someKey' }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
),
);
});
it('should require each item to have a value', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
);
});
describe(AssetMetadataKey.MobileApp, () => {
it('should accept valid data and pass to service correctly', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
});
expect(status).toBe(200);
});
it('should work without iCloudId', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: {} }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: {} }],
});
expect(status).toBe(200);
});
});
});
describe('GET /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
),
);
});
});
describe('DELETE /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
);
});
});
});
+42 -7
View File
@@ -6,6 +6,9 @@ import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
AssetMetadataUpsertDto,
AssetStatsDto,
AssetStatsResponseDto,
DeviceIdDto,
@@ -13,7 +16,7 @@ import {
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { RouteKey } from 'src/enum';
import { Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation';
@@ -24,7 +27,7 @@ export class AssetController {
constructor(private service: AssetService) {}
@Get('random')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
@@ -44,7 +47,7 @@ export class AssetController {
}
@Get('statistics')
@Authenticated()
@Authenticated({ permission: Permission.AssetStatistics })
getAssetStatistics(@Auth() auth: AuthDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@@ -57,27 +60,27 @@ export class AssetController {
}
@Put()
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.AssetUpdate })
updateAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -85,4 +88,36 @@ export class AssetController {
): Promise<AssetResponseDto> {
return this.service.update(auth, id, dto);
}
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
return this.service.getMetadata(auth, id);
}
@Put(':id/metadata')
@Authenticated({ permission: Permission.AssetUpdate })
updateAssetMetadata(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetMetadataUpsertDto,
): Promise<AssetMetadataResponseDto[]> {
return this.service.upsertMetadata(auth, id, dto);
}
@Get(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadataByKey(
@Auth() auth: AuthDto,
@Param() { id, key }: AssetMetadataRouteParams,
): Promise<AssetMetadataResponseDto> {
return this.service.getMetadataByKey(auth, id, key);
}
@Delete(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
}
@@ -0,0 +1,18 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuthAdminService } from 'src/services/auth-admin.service';
@ApiTags('Auth (admin)')
@Controller('admin/auth')
export class AuthAdminController {
constructor(private service: AuthAdminService) {}
@Post('unlink-all')
@Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise<void> {
return this.service.unlinkAll(auth);
}
}
+12 -9
View File
@@ -16,7 +16,7 @@ import {
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie } from 'src/enum';
import { AuthType, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@@ -49,22 +49,22 @@ export class AuthController {
}
@Post('validateToken')
@Authenticated({ permission: false })
@HttpCode(HttpStatus.OK)
@Authenticated()
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
@Authenticated({ permission: Permission.AuthChangePassword })
@HttpCode(HttpStatus.OK)
@Authenticated()
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
async logout(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@@ -87,33 +87,36 @@ export class AuthController {
}
@Post('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeCreate })
@HttpCode(HttpStatus.NO_CONTENT)
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}
@@ -1,9 +1,9 @@
import { Readable } from 'node:stream';
import { DownloadController } from 'src/controllers/download.controller';
import { DownloadService } from 'src/services/download.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
import { Readable } from 'typeorm/platform/PlatformTools.js';
describe(DownloadController.name, () => {
let ctx: ControllerContext;
@@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { DownloadService } from 'src/services/download.service';
import { asStreamableFile } from 'src/utils/file';
@@ -13,15 +14,15 @@ export class DownloadController {
constructor(private service: DownloadService) {}
@Post('info')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
}
@Post('archive')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@FileResponse()
@Authenticated({ sharedLink: true })
@HttpCode(HttpStatus.OK)
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}
@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
import { UUIDParamDto } from 'src/validation';
@@ -13,19 +14,21 @@ export class DuplicateController {
constructor(private service: DuplicateService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.DuplicateRead })
getAssetDuplicates(@Auth() auth: AuthDto): Promise<DuplicateResponseDto[]> {
return this.service.getDuplicates(auth);
}
@Delete()
@Authenticated()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -42,7 +42,8 @@ export class FaceController {
@Delete(':id')
@Authenticated({ permission: Permission.FaceDelete })
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) {
@HttpCode(HttpStatus.NO_CONTENT)
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto): Promise<void> {
return this.service.deleteFace(auth, id, dto);
}
}
+4 -2
View File
@@ -1,9 +1,10 @@
import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.controller';
import { APIKeyController } from 'src/controllers/api-key.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -33,13 +34,14 @@ import { UserController } from 'src/controllers/user.controller';
import { ViewController } from 'src/controllers/view.controller';
export const controllers = [
APIKeyController,
ApiKeyController,
ActivityController,
AlbumController,
AppController,
AssetController,
AssetMediaController,
AuthController,
AuthAdminController,
DownloadController,
DuplicateController,
FaceController,
+6 -4
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
@@ -10,19 +11,20 @@ export class JobController {
constructor(private service: JobService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobRead, admin: true })
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.service.getAllJobsStatus();
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobCreate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobCreate, admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
return this.service.handleCommand(id, dto);
}
+4 -4
View File
@@ -43,15 +43,15 @@ export class LibraryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Post(':id/validate')
@HttpCode(200)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
// TODO: change endpoint to validate current settings instead
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
return this.service.validate(id, dto);
@@ -64,9 +64,9 @@ export class LibraryController {
}
@Post(':id/scan')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
scanLibrary(@Param() { id }: UUIDParamDto) {
@HttpCode(HttpStatus.NO_CONTENT)
scanLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.queueScan(id);
}
}
+4 -4
View File
@@ -32,7 +32,7 @@ export class MemoryController {
}
@Get('statistics')
@Authenticated({ permission: Permission.MemoryRead })
@Authenticated({ permission: Permission.MemoryStatistics })
memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryStatisticsResponseDto> {
return this.service.statistics(auth, dto);
}
@@ -54,14 +54,14 @@ export class MemoryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.MemoryDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.MemoryAssetCreate })
addMemoryAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -71,8 +71,8 @@ export class MemoryController {
}
@Delete(':id/assets')
@Authenticated({ permission: Permission.MemoryAssetDelete })
@HttpCode(HttpStatus.OK)
@Authenticated()
removeMemoryAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -25,15 +25,15 @@ export class NotificationAdminController {
}
@Post('test-email')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto);
}
@Post('templates/:name')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
getNotificationTemplateAdmin(
@Auth() auth: AuthDto,
@Param('name') name: EmailTemplate,
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -26,12 +26,14 @@ export class NotificationController {
@Put()
@Authenticated({ permission: Permission.NotificationUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -54,6 +56,7 @@ export class NotificationController {
@Delete(':id')
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+2 -1
View File
@@ -70,6 +70,7 @@ export class OAuthController {
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@@ -79,8 +80,8 @@ export class OAuthController {
}
@Post('unlink')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
@@ -0,0 +1,101 @@
import { PartnerController } from 'src/controllers/partner.controller';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerService } from 'src/services/partner.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(PartnerController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PartnerService);
beforeAll(async () => {
ctx = await controllerSetup(PartnerController, [
{ provide: PartnerService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require a direction`, async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'direction should not be empty',
expect.stringContaining('direction must be one of the following values:'),
]),
);
});
it(`should require direction to be an enum`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/partners`)
.query({ direction: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
);
});
});
describe('POST /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require sharedWithId to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/partners`)
.send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('PUT /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/partners/invalid`)
.send({ inTimeline: true })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('DELETE /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete(`/partners/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
});
+15 -6
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
@@ -18,10 +19,17 @@ export class PartnerController {
return this.service.search(auth, dto);
}
@Post(':id')
@Post()
@Authenticated({ permission: Permission.PartnerCreate })
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id);
createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise<PartnerResponseDto> {
return this.service.create(auth, dto);
}
@Post(':id')
@EndpointLifecycle({ deprecatedAt: 'v1.141.0' })
@Authenticated({ permission: Permission.PartnerCreate })
createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, { sharedWithId: id });
}
@Put(':id')
@@ -29,13 +37,14 @@ export class PartnerController {
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdatePartnerDto,
@Body() dto: PartnerUpdateDto,
): Promise<PartnerResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.PartnerDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+3 -2
View File
@@ -63,8 +63,8 @@ export class PersonController {
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -86,8 +86,8 @@ export class PersonController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -122,6 +122,7 @@ export class PersonController {
@Post(':id/merge')
@Authenticated({ permission: Permission.PersonMerge })
@HttpCode(HttpStatus.OK)
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -128,12 +128,6 @@ describe(SearchController.name, () => {
await request(ctx.getHttpServer()).post('/search/smart');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a query', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
});
});
describe('GET /search/explore', () => {
+19 -10
View File
@@ -4,6 +4,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
import {
LargeAssetSearchDto,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
@@ -16,6 +17,7 @@ import {
SmartSearchDto,
StatisticsSearchDto,
} from 'src/dtos/search.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SearchService } from 'src/services/search.service';
@@ -25,59 +27,66 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post('metadata')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchAssets(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@Post('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
return this.service.searchStatistics(auth, dto);
}
@Post('random')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchRandom(auth, dto);
}
@Post('smart')
@Post('large-assets')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);
}
@Post('smart')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}
@Get('explore')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(auth);
}
@Get('person')
@Authenticated()
@Authenticated({ permission: Permission.PersonRead })
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('places')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('cities')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getAssetsByCity(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
return this.service.getAssetsByCity(auth);
}
@Get('suggestions')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
// TODO fix open api generation to indicate that results can be nullable
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
+21 -19
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
@@ -15,6 +15,7 @@ import {
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerService } from 'src/services/server.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@@ -30,19 +31,19 @@ export class ServerController {
) {}
@Get('about')
@Authenticated()
@Authenticated({ permission: Permission.ServerAbout })
getAboutInfo(): Promise<ServerAboutResponseDto> {
return this.service.getAboutInfo();
}
@Get('apk-links')
@Authenticated()
@Authenticated({ permission: Permission.ServerApkLinks })
getApkLinks(): ServerApkLinksDto {
return this.service.getApkLinks();
}
@Get('storage')
@Authenticated()
@Authenticated({ permission: Permission.ServerStorage })
getStorage(): Promise<ServerStorageResponseDto> {
return this.service.getStorage();
}
@@ -78,7 +79,7 @@ export class ServerController {
}
@Get('statistics')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ServerStatistics, admin: true })
getServerStatistics(): Promise<ServerStatsResponseDto> {
return this.service.getStatistics();
}
@@ -88,27 +89,28 @@ export class ServerController {
return this.service.getSupportedMediaTypes();
}
@Put('license')
@Authenticated({ admin: true })
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@Delete('license')
@Authenticated({ admin: true })
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('license')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ServerLicenseRead, admin: true })
@ApiNotFoundResponse()
getServerLicense(): Promise<LicenseResponseDto> {
return this.service.getLicense();
}
@Put('license')
@Authenticated({ permission: Permission.ServerLicenseUpdate, admin: true })
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@Delete('license')
@Authenticated({ permission: Permission.ServerLicenseDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('version-check')
@Authenticated()
@Authenticated({ permission: Permission.ServerVersionCheck })
getVersionCheck(): Promise<VersionCheckStateResponseDto> {
return this.systemMetadataService.getVersionCheckState();
}
@@ -1,4 +1,18 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -73,6 +87,7 @@ export class SharedLinkController {
@Delete(':id')
@Authenticated({ permission: Permission.SharedLinkDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+9 -2
View File
@@ -6,7 +6,7 @@ import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { StackService } from 'src/services/stack.service';
import { UUIDParamDto } from 'src/validation';
import { UUIDAssetIDParamDto, UUIDParamDto } from 'src/validation';
@ApiTags('Stacks')
@Controller('stacks')
@@ -49,9 +49,16 @@ export class StackController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Delete(':id/assets/:assetId')
@Authenticated({ permission: Permission.StackUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
removeAssetFromStack(@Auth() auth: AuthDto, @Param() dto: UUIDAssetIDParamDto): Promise<void> {
return this.service.removeAsset(auth, dto);
}
}
+8 -7
View File
@@ -12,6 +12,7 @@ import {
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { SyncService } from 'src/services/sync.service';
@@ -25,23 +26,23 @@ export class SyncController {
) {}
@Post('full-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@Post('delta-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@Post('stream')
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
@HttpCode(HttpStatus.OK)
@Authenticated()
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
try {
await this.service.stream(auth, res, dto);
@@ -52,22 +53,22 @@ export class SyncController {
}
@Get('ack')
@Authenticated()
@Authenticated({ permission: Permission.SyncCheckpointRead })
getSyncAck(@Auth() auth: AuthDto): Promise<SyncAckDto[]> {
return this.service.getAcks(auth);
}
@Post('ack')
@Authenticated({ permission: Permission.SyncCheckpointUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) {
return this.service.setAcks(auth, dto);
}
@Delete('ack')
@Authenticated({ permission: Permission.SyncCheckpointDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto) {
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto): Promise<void> {
return this.service.deleteAcks(auth, dto);
}
}
@@ -21,8 +21,8 @@ export class SystemMetadataController {
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
+1 -1
View File
@@ -57,8 +57,8 @@ export class TagController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.TagDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+3 -3
View File
@@ -13,22 +13,22 @@ export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@Post('restore')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@Post('restore/assets')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}
@@ -0,0 +1,79 @@
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserAdminCreateDto } from 'src/dtos/user.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserAdminService } from 'src/services/user-admin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(UserAdminController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(UserAdminService);
beforeAll(async () => {
ctx = await controllerSetup(UserAdminController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: UserAdminService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
password: 'test',
name: 'Test User',
quotaSizeInBytes: 1.2,
};
const { status, body } = await request(ctx.getHttpServer())
.post(`/admin/users`)
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
describe('GET /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/admin/users/${factory.uuid()}`)
.set('Authorization', `Bearer token`)
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
});
+19 -17
View File
@@ -21,7 +21,7 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { RouteKey } from 'src/enum';
import { Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -38,31 +38,31 @@ export class UserController {
) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(auth);
}
@Get('me')
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
getMyUser(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.getMe(auth);
}
@Put('me')
@Authenticated()
@Authenticated({ permission: Permission.UserUpdate })
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get('me/preferences')
@Authenticated()
@Authenticated({ permission: Permission.UserPreferenceRead })
getMyPreferences(@Auth() auth: AuthDto): Promise<UserPreferencesResponseDto> {
return this.service.getMyPreferences(auth);
}
@Put('me/preferences')
@Authenticated()
@Authenticated({ permission: Permission.UserPreferenceUpdate })
updateMyPreferences(
@Auth() auth: AuthDto,
@Body() dto: UserPreferencesUpdateDto,
@@ -71,52 +71,54 @@ export class UserController {
}
@Get('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseRead })
getUserLicense(@Auth() auth: AuthDto): Promise<LicenseResponseDto> {
return this.service.getLicense(auth);
}
@Put('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseUpdate })
async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(auth, license);
}
@Delete('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserLicense(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteLicense(auth);
}
@Get('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingRead })
getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
return this.service.getOnboarding(auth);
}
@Put('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingUpdate })
async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
return this.service.setOnboarding(auth, Onboarding);
}
@Delete('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteOnboarding(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id);
}
@Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate })
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Post('profile-image')
@Authenticated()
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
@@ -125,15 +127,15 @@ export class UserController {
}
@Delete('profile-image')
@Authenticated({ permission: Permission.UserProfileImageDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Get(':id/profile-image')
@FileResponse()
@Authenticated()
@Authenticated({ permission: Permission.UserProfileImageRead })
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
await sendFile(res, next, () => this.service.getProfileImage(id), this.logger);
}
+4 -1
View File
@@ -2,7 +2,6 @@ import { StorageCore } from 'src/cores/storage.core';
import { vitest } from 'vitest';
vitest.mock('src/constants', () => ({
APP_MEDIA_LOCATION: '/photos',
ADDED_IN_PREFIX: 'This property was added in ',
DEPRECATED_IN_PREFIX: 'This property was deprecated in ',
IWorker: 'IWorker',
@@ -10,6 +9,10 @@ vitest.mock('src/constants', () => ({
describe('StorageCore', () => {
describe('isImmichPath', () => {
beforeAll(() => {
StorageCore.setMediaLocation('/photos');
});
it('should return true for APP_MEDIA_LOCATION path', () => {
const immichPath = '/photos';
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
+19 -4
View File
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -32,6 +31,8 @@ export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null;
let mediaLocation: string | undefined;
export class StorageCore {
private constructor(
private assetRepository: AssetRepository,
@@ -42,7 +43,9 @@ export class StorageCore {
private storageRepository: StorageRepository,
private systemMetadataRepository: SystemMetadataRepository,
private logger: LoggingRepository,
) {}
) {
this.logger.setContext(StorageCore.name);
}
static create(
assetRepository: AssetRepository,
@@ -74,6 +77,18 @@ export class StorageCore {
instance = null;
}
static getMediaLocation(): string {
if (mediaLocation === undefined) {
throw new Error('Media location is not set.');
}
return mediaLocation;
}
static setMediaLocation(location: string) {
mediaLocation = location;
}
static getFolderLocation(folder: StorageFolder, userId: string) {
return join(StorageCore.getBaseFolder(folder), userId);
}
@@ -83,7 +98,7 @@ export class StorageCore {
}
static getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder);
return join(StorageCore.getMediaLocation(), folder);
}
static getPersonThumbnailPath(person: ThumbnailPathEntity) {
@@ -108,7 +123,7 @@ export class StorageCore {
static isImmichPath(path: string) {
const resolvedPath = resolve(path);
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION);
const resolvedAppMediaLocation = StorageCore.getMediaLocation();
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
? resolvedAppMediaLocation
+4 -2
View File
@@ -192,6 +192,7 @@ export type SharedLink = {
showExif: boolean;
type: SharedLinkType;
userId: string;
slug: string | null;
};
export type Album = Selectable<AlbumTable> & {
@@ -201,7 +202,6 @@ export type Album = Selectable<AlbumTable> & {
export type AuthSession = {
id: string;
isPendingSyncReset: boolean;
hasElevatedPermission: boolean;
};
@@ -308,7 +308,7 @@ export const columns = {
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.isPendingSyncReset', 'session.updatedAt', 'session.pinExpiresAt'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt'],
authSharedLink: [
'shared_link.id',
'shared_link.userId',
@@ -353,9 +353,11 @@ export const columns = {
'asset.duration',
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
],
syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'asset_exif.assetId',
+14 -5
View File
@@ -1,5 +1,5 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
@@ -87,7 +87,7 @@ export function Chunked(
return Promise.all(
chunks(argument, chunkSize).map(async (chunk) => {
await Reflect.apply(originalMethod, this, [
return await Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
...arguments_.slice(parameterIndex + 1),
@@ -103,7 +103,7 @@ export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator
}
export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
return Chunked({ ...options, mergeFn: setUnion });
return Chunked({ ...options, mergeFn: (args: Set<any>[]) => setUnion(...args) });
}
const UUID = '00000000-0000-4000-a000-000000000000';
@@ -159,12 +159,21 @@ type LifecycleMetadata = {
deprecatedAt?: LifecycleRelease;
};
export const EndpointLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => {
export const EndpointLifecycle = ({
addedAt,
deprecatedAt,
description,
...options
}: LifecycleMetadata & ApiOperationOptions) => {
const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })];
if (deprecatedAt) {
decorators.push(
ApiTags('Deprecated'),
ApiOperation({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }),
ApiOperation({
deprecated: true,
description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''),
...options,
}),
);
}
+15
View File
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
@@ -54,6 +55,20 @@ export class CreateAlbumDto {
assetIds?: string[];
}
export class AlbumsAddAssetsDto {
@ValidateUUID({ each: true })
albumIds!: string[];
@ValidateUUID({ each: true })
assetIds!: string[];
}
export class AlbumsAddAssetsResponseDto {
success!: boolean;
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
error?: BulkIdErrorReason;
}
export class UpdateAlbumDto {
@Optional()
@IsString()
+17 -1
View File
@@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
@@ -64,6 +66,20 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;
@Transform(({ value }) => {
try {
const json = JSON.parse(value);
const items = Array.isArray(json) ? json : [json];
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
} catch {
throw new BadRequestException(['metadata must be valid JSON']);
}
})
@Optional()
@ValidateNested({ each: true })
@IsArray()
metadata!: AssetMetadataUpsertItemDto[];
@ApiProperty({ type: 'string', format: 'binary', required: false })
[UploadFieldName.SIDECAR_DATA]?: any;
}
+8
View File
@@ -37,6 +37,13 @@ export class SanitizedAssetResponseDto {
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
@ApiProperty({
type: 'string',
format: 'date-time',
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
example: '2024-01-15T20:30:00.000Z',
})
createdAt!: Date;
deviceAssetId!: string;
deviceId!: string;
ownerId!: string;
@@ -190,6 +197,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
return {
id: entity.id,
createdAt: entity.createdAt,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
+64 -2
View File
@@ -1,21 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsInt,
IsLatitude,
IsLongitude,
IsNotEmpty,
IsObject,
IsPositive,
IsString,
IsTimeZone,
Max,
Min,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@IsNotEmpty()
@@ -65,6 +70,16 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
@Optional()
duplicateId?: string | null;
@IsNotSiblingOf(['dateTimeOriginal'])
@Optional()
@IsInt()
dateTimeRelative?: number;
@IsNotSiblingOf(['dateTimeOriginal'])
@IsTimeZone()
@Optional()
timeZone?: string;
}
export class UpdateAssetDto extends UpdateAssetBase {
@@ -124,6 +139,53 @@ export class AssetStatsResponseDto {
total!: number;
}
export class AssetMetadataRouteParams {
@ValidateUUID()
id!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
}
export class AssetMetadataUpsertDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto)
items!: AssetMetadataUpsertItemDto[];
}
export class AssetMetadataUpsertItemDto implements AssetMetadataItem {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@IsObject()
@ValidateNested()
@Type((options) => {
switch (options?.object.key) {
case AssetMetadataKey.MobileApp: {
return AssetMetadataMobileAppDto;
}
default: {
return Object;
}
}
})
value!: AssetMetadata[AssetMetadataKey];
}
export class AssetMetadataMobileAppDto {
@IsString()
@Optional()
iCloudId?: string;
}
export class AssetMetadataResponseDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
value!: object;
updatedAt!: Date;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.Image],
+7 -2
View File
@@ -1,9 +1,14 @@
import { IsNotEmpty } from 'class-validator';
import { UserResponseDto } from 'src/dtos/user.dto';
import { PartnerDirection } from 'src/repositories/partner.repository';
import { ValidateEnum } from 'src/validation';
import { ValidateEnum, ValidateUUID } from 'src/validation';
export class UpdatePartnerDto {
export class PartnerCreateDto {
@ValidateUUID()
sharedWithId!: string;
}
export class PartnerUpdateDto {
@IsNotEmpty()
inTimeline!: boolean;
}
+19 -13
View File
@@ -6,7 +6,7 @@ import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
class BaseSearchDto {
@ValidateUUID({ optional: true, nullable: true })
@@ -126,6 +126,15 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
withPeople?: boolean;
}
export class LargeAssetSearchDto extends BaseSearchWithResultsDto {
@Optional()
@IsInt()
@Min(0)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
minFileSize?: number;
}
export class MetadataSearchDto extends RandomSearchDto {
@ValidateUUID({ optional: true })
id?: string;
@@ -135,9 +144,7 @@ export class MetadataSearchDto extends RandomSearchDto {
@Optional()
deviceAssetId?: string;
@IsString()
@IsNotEmpty()
@Optional()
@ValidateString({ optional: true, trim: true })
description?: string;
@IsString()
@@ -145,9 +152,7 @@ export class MetadataSearchDto extends RandomSearchDto {
@Optional()
checksum?: string;
@IsString()
@IsNotEmpty()
@Optional()
@ValidateString({ optional: true, trim: true })
originalFileName?: string;
@IsString()
@@ -181,16 +186,17 @@ export class MetadataSearchDto extends RandomSearchDto {
}
export class StatisticsSearchDto extends BaseSearchDto {
@IsString()
@IsNotEmpty()
@Optional()
@ValidateString({ optional: true, trim: true })
description?: string;
}
export class SmartSearchDto extends BaseSearchWithResultsDto {
@IsString()
@IsNotEmpty()
query!: string;
@ValidateString({ optional: true, trim: true })
query?: string;
@ValidateUUID({ optional: true })
@Optional()
queryAssetId?: string;
@IsString()
@IsNotEmpty()
+3 -2
View File
@@ -1,4 +1,4 @@
import { IsInt, IsPositive, IsString } from 'class-validator';
import { Equals, IsInt, IsPositive, IsString } from 'class-validator';
import { Session } from 'src/database';
import { Optional, ValidateBoolean } from 'src/validation';
@@ -22,7 +22,8 @@ export class SessionCreateDto {
export class SessionUpdateDto {
@ValidateBoolean({ optional: true })
isPendingSyncReset?: boolean;
@Equals(true)
isPendingSyncReset?: true;
}
export class SessionResponseDto {
+23 -9
View File
@@ -22,13 +22,17 @@ export class SharedLinkCreateDto {
@ValidateUUID({ optional: true })
albumId?: string;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
@Optional()
description?: string;
description?: string | null;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
@Optional()
password?: string;
password?: string | null;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
slug?: string | null;
@ValidateDate({ optional: true, nullable: true })
expiresAt?: Date | null = null;
@@ -44,16 +48,22 @@ export class SharedLinkCreateDto {
}
export class SharedLinkEditDto {
@Optional()
description?: string;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
description?: string | null;
@Optional()
password?: string;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
password?: string | null;
@Optional({ nullable: true, emptyToNull: true })
@IsString()
slug?: string | null;
@Optional({ nullable: true })
expiresAt?: Date | null;
@Optional()
@ValidateBoolean({ optional: true })
allowUpload?: boolean;
@ValidateBoolean({ optional: true })
@@ -99,6 +109,8 @@ export class SharedLinkResponseDto {
allowDownload!: boolean;
showMetadata!: boolean;
slug!: string | null;
}
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
@@ -118,6 +130,7 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
}
@@ -141,5 +154,6 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLink
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
}
+55 -4
View File
@@ -4,12 +4,14 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AlbumUserRole,
AssetMetadataKey,
AssetOrder,
AssetType,
AssetVisibility,
MemoryType,
SyncEntityType,
SyncRequestType,
UserAvatarColor,
UserMetadataKey,
} from 'src/enum';
import { UserMetadata } from 'src/types';
@@ -58,7 +60,23 @@ export class SyncUserV1 {
id!: string;
name!: string;
email!: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', nullable: true })
avatarColor!: UserAvatarColor | null;
deletedAt!: Date | null;
hasProfileImage!: boolean;
profileChangedAt!: Date;
}
@ExtraModel()
export class SyncAuthUserV1 extends SyncUserV1 {
isAdmin!: boolean;
pinCode!: string | null;
oauthId!: string;
storageLabel!: string | null;
@ApiProperty({ type: 'integer' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer' })
quotaUsageInBytes!: number;
}
@ExtraModel()
@@ -98,6 +116,7 @@ export class SyncAssetV1 {
visibility!: AssetVisibility;
livePhotoVideoId!: string | null;
stackId!: string | null;
libraryId!: string | null;
}
@ExtraModel()
@@ -144,6 +163,21 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
value!: object;
}
@ExtraModel()
export class SyncAssetMetadataDeleteV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
}
@ExtraModel()
export class SyncAlbumDeleteV1 {
albumId!: string;
@@ -261,11 +295,17 @@ export class SyncAssetFaceV1 {
id!: string;
assetId!: string;
personId!: string | null;
@ApiProperty({ type: 'integer' })
imageWidth!: number;
@ApiProperty({ type: 'integer' })
imageHeight!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY2!: number;
sourceType!: string;
}
@@ -278,14 +318,16 @@ export class SyncAssetFaceDeleteV1 {
@ExtraModel()
export class SyncUserMetadataV1 {
userId!: string;
key!: string;
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' })
key!: UserMetadataKey;
value!: UserMetadata[UserMetadataKey];
}
@ExtraModel()
export class SyncUserMetadataDeleteV1 {
userId!: string;
key!: string;
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' })
key!: UserMetadataKey;
}
@ExtraModel()
@@ -294,13 +336,19 @@ export class SyncAckV1 {}
@ExtraModel()
export class SyncResetV1 {}
@ExtraModel()
export class SyncCompleteV1 {}
export type SyncItem = {
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
[SyncEntityType.UserV1]: SyncUserV1;
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
[SyncEntityType.PartnerV1]: SyncPartnerV1;
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
[SyncEntityType.AssetV1]: SyncAssetV1;
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
@@ -312,9 +360,11 @@ export type SyncItem = {
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.AlbumAssetV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1;
@@ -335,6 +385,7 @@ export type SyncItem = {
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
[SyncEntityType.SyncCompleteV1]: SyncCompleteV1;
[SyncEntityType.SyncResetV1]: SyncResetV1;
};
+17 -7
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Transform, Type } from 'class-transformer';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsInt,
@@ -15,7 +15,6 @@ import {
ValidateNested,
} from 'class-validator';
import { SystemConfig } from 'src/config';
import { PropertyLifecycle } from 'src/decorators';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
import {
AudioCodec,
@@ -257,21 +256,32 @@ class SystemConfigLoggingDto {
level!: LogLevel;
}
class MachineLearningAvailabilityChecksDto {
@ValidateBoolean()
enabled!: boolean;
@IsInt()
timeout!: number;
@IsInt()
interval!: number;
}
class SystemConfigMachineLearningDto {
@ValidateBoolean()
enabled!: boolean;
@PropertyLifecycle({ deprecatedAt: 'v1.122.0' })
@Exclude()
url?: string;
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
@ArrayMinSize(1)
@Transform(({ obj, value }) => (obj.url ? [obj.url] : value))
@ValidateIf((dto) => dto.enabled)
@ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 })
urls!: string[];
@Type(() => MachineLearningAvailabilityChecksDto)
@ValidateNested()
@IsObject()
availabilityChecks!: MachineLearningAvailabilityChecksDto;
@Type(() => CLIPConfig)
@ValidateNested()
@IsObject()
+22
View File
@@ -53,6 +53,12 @@ export class TimeBucketDto {
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
})
visibility?: AssetVisibility;
@ValidateBoolean({
optional: true,
description: 'Include location data in the response',
})
withCoordinates?: boolean;
}
export class TimeBucketAssetDto extends TimeBucketDto {
@@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto {
description: 'Array of country names extracted from EXIF GPS data',
})
country!: (string | null)[];
@ApiProperty({
type: 'array',
required: false,
items: { type: 'number', nullable: true },
description: 'Array of latitude coordinates extracted from EXIF GPS data',
})
latitude!: number[];
@ApiProperty({
type: 'array',
required: false,
items: { type: 'number', nullable: true },
description: 'Array of longitude coordinates extracted from EXIF GPS data',
})
longitude!: number[];
}
export class TimeBucketsResponseDto {
+3 -3
View File
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
storageLabel?: string | null;
@Optional({ nullable: true })
@IsNumber()
@IsInt()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
shouldChangePassword?: boolean;
@Optional({ nullable: true })
@IsNumber()
@IsInt()
@Min(0)
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
+2 -2
View File
@@ -29,8 +29,8 @@ export const AlbumUpdateEmail = ({
</Text>
<Text>
New media has been added to <strong>{albumName}</strong>,
<br /> check it out!
New media has been added to <strong>{albumName}</strong>.
<br /> Check it out!
</Text>
</>
);
@@ -1,12 +1,12 @@
import React from 'react';
import { Button, ButtonProps } from '@react-email/components';
import { Button, ButtonProps, Text } from '@react-email/components';
export const ImmichButton = ({ children, ...props }: ButtonProps) => (
<Button
{...props}
className="py-3 px-8 border bg-immich-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 font-bold uppercase"
className="border bg-immich-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 font-bold uppercase"
>
{children}
<Text className="my-3 mx-8">{children}</Text>
</Button>
);
+92 -11
View File
@@ -17,12 +17,14 @@ export enum ImmichHeader {
UserToken = 'x-immich-user-token',
SessionToken = 'x-immich-session-token',
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
}
export enum ImmichQuery {
SharedLinkKey = 'key',
SharedLinkSlug = 'slug',
ApiKey = 'apiKey',
SessionKey = 'sessionKey',
}
@@ -87,31 +89,45 @@ export enum Permission {
AssetRead = 'asset.read',
AssetUpdate = 'asset.update',
AssetDelete = 'asset.delete',
AssetStatistics = 'asset.statistics',
AssetShare = 'asset.share',
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AlbumCreate = 'album.create',
AlbumRead = 'album.read',
AlbumUpdate = 'album.update',
AlbumDelete = 'album.delete',
AlbumStatistics = 'album.statistics',
AlbumAddAsset = 'album.addAsset',
AlbumRemoveAsset = 'album.removeAsset',
AlbumShare = 'album.share',
AlbumDownload = 'album.download',
AlbumAssetCreate = 'albumAsset.create',
AlbumAssetDelete = 'albumAsset.delete',
AlbumUserCreate = 'albumUser.create',
AlbumUserUpdate = 'albumUser.update',
AlbumUserDelete = 'albumUser.delete',
AuthChangePassword = 'auth.changePassword',
AuthDeviceDelete = 'authDevice.delete',
ArchiveRead = 'archive.read',
DuplicateRead = 'duplicate.read',
DuplicateDelete = 'duplicate.delete',
FaceCreate = 'face.create',
FaceRead = 'face.read',
FaceUpdate = 'face.update',
FaceDelete = 'face.delete',
JobCreate = 'job.create',
JobRead = 'job.read',
LibraryCreate = 'library.create',
LibraryRead = 'library.read',
LibraryUpdate = 'library.update',
@@ -125,6 +141,10 @@ export enum Permission {
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
MemoryDelete = 'memory.delete',
MemoryStatistics = 'memory.statistics',
MemoryAssetCreate = 'memoryAsset.create',
MemoryAssetDelete = 'memoryAsset.delete',
NotificationCreate = 'notification.create',
NotificationRead = 'notification.read',
@@ -144,6 +164,20 @@ export enum Permission {
PersonMerge = 'person.merge',
PersonReassign = 'person.reassign',
PinCodeCreate = 'pinCode.create',
PinCodeUpdate = 'pinCode.update',
PinCodeDelete = 'pinCode.delete',
ServerAbout = 'server.about',
ServerApkLinks = 'server.apkLinks',
ServerStorage = 'server.storage',
ServerStatistics = 'server.statistics',
ServerVersionCheck = 'server.versionCheck',
ServerLicenseRead = 'serverLicense.read',
ServerLicenseUpdate = 'serverLicense.update',
ServerLicenseDelete = 'serverLicense.delete',
SessionCreate = 'session.create',
SessionRead = 'session.read',
SessionUpdate = 'session.update',
@@ -160,6 +194,11 @@ export enum Permission {
StackUpdate = 'stack.update',
StackDelete = 'stack.delete',
SyncStream = 'sync.stream',
SyncCheckpointRead = 'syncCheckpoint.read',
SyncCheckpointUpdate = 'syncCheckpoint.update',
SyncCheckpointDelete = 'syncCheckpoint.delete',
SystemConfigRead = 'systemConfig.read',
SystemConfigUpdate = 'systemConfig.update',
@@ -172,10 +211,32 @@ export enum Permission {
TagDelete = 'tag.delete',
TagAsset = 'tag.asset',
AdminUserCreate = 'admin.user.create',
AdminUserRead = 'admin.user.read',
AdminUserUpdate = 'admin.user.update',
AdminUserDelete = 'admin.user.delete',
UserRead = 'user.read',
UserUpdate = 'user.update',
UserLicenseCreate = 'userLicense.create',
UserLicenseRead = 'userLicense.read',
UserLicenseUpdate = 'userLicense.update',
UserLicenseDelete = 'userLicense.delete',
UserOnboardingRead = 'userOnboarding.read',
UserOnboardingUpdate = 'userOnboarding.update',
UserOnboardingDelete = 'userOnboarding.delete',
UserPreferenceRead = 'userPreference.read',
UserPreferenceUpdate = 'userPreference.update',
UserProfileImageCreate = 'userProfileImage.create',
UserProfileImageRead = 'userProfileImage.read',
UserProfileImageUpdate = 'userProfileImage.update',
UserProfileImageDelete = 'userProfileImage.delete',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharedLinkType {
@@ -215,6 +276,10 @@ export enum UserMetadataKey {
Onboarding = 'onboarding',
}
export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@@ -355,6 +420,11 @@ export enum LogLevel {
Fatal = 'fatal',
}
export enum ApiCustomExtension {
Permission = 'x-immich-permission',
AdminOnly = 'x-immich-admin-only',
}
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
@@ -417,6 +487,8 @@ export enum DatabaseExtension {
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
// Detect and configure the media location before jobs are queued which may use it
StorageService = -195,
// Other services may need to queue jobs on bootstrap.
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
@@ -458,6 +530,7 @@ export enum JobName {
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup',
DatabaseBackup = 'DatabaseBackup',
@@ -498,8 +571,7 @@ export enum JobName {
SendMail = 'SendMail',
SidecarQueueAll = 'SidecarQueueAll',
SidecarDiscovery = 'SidecarDiscovery',
SidecarSync = 'SidecarSync',
SidecarCheck = 'SidecarCheck',
SidecarWrite = 'SidecarWrite',
SmartSearchQueueAll = 'SmartSearchQueueAll',
@@ -559,6 +631,8 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
PartnersV1 = 'PartnersV1',
@@ -573,12 +647,16 @@ export enum SyncRequestType {
}
export enum SyncEntityType {
AuthUserV1 = 'AuthUserV1',
UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1',
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
@@ -599,9 +677,11 @@ export enum SyncEntityType {
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
AlbumAssetV1 = 'AlbumAssetV1',
AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetExifV1 = 'AlbumAssetExifV1',
AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1',
AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
AlbumToAssetV1 = 'AlbumToAssetV1',
@@ -628,6 +708,7 @@ export enum SyncEntityType {
SyncAckV1 = 'SyncAckV1',
SyncResetV1 = 'SyncResetV1',
SyncCompleteV1 = 'SyncCompleteV1',
}
export enum NotificationLevel {
+17 -6
View File
@@ -7,28 +7,39 @@ import {
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { ApiBearerAuth, ApiCookieAuth, ApiExtension, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { ApiCustomExtension, ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => {
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(MetadataKey.ApiKeySecurity),
SetMetadata(MetadataKey.AuthRoute, options || {}),
SetMetadata(MetadataKey.AuthRoute, options),
];
if ((options as AdminRoute).admin) {
decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true));
}
if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
}
if ((options as SharedLinkRoute)?.sharedLink) {
decorators.push(ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }));
decorators.push(
ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }),
ApiQuery({ name: ImmichQuery.SharedLinkSlug, type: String, required: false }),
);
}
return applyDecorators(...decorators);
@@ -12,7 +12,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asRequest, mapToUploadFile } from 'src/utils/asset.util';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
const file = files[property]?.[0];
@@ -99,18 +99,21 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asRequest(request, file)),
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
@@ -1,23 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserTable1645130759468 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(`
create table if not exists users
(
id uuid default uuid_generate_v4() not null
constraint "PK_a3ffb1c0c8416b9fc6f907b7433"
primary key,
email varchar not null,
password varchar not null,
salt varchar not null,
"createdAt" timestamp default now() not null
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table users`);
}
}
@@ -1,26 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDeviceInfoTable1645130777674 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists device_info
(
id serial
constraint "PK_b1c15a80b0a4e5f4eebadbdd92c"
primary key,
"userId" varchar not null,
"deviceId" varchar not null,
"deviceType" varchar not null,
"notificationToken" varchar,
"createdAt" timestamp default now() not null,
"isAutoBackup" boolean default false not null,
constraint "UQ_ebad78f36b10d15fbea8560e107"
unique ("userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table device_info`);
}
}
@@ -1,31 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAssetsTable1645130805273 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists assets
(
id uuid default uuid_generate_v4() not null
constraint "PK_da96729a8b113377cfb6a62439c"
primary key,
"deviceAssetId" varchar not null,
"userId" varchar not null,
"deviceId" varchar not null,
type varchar not null,
"originalPath" varchar not null,
"resizePath" varchar,
"createdAt" varchar not null,
"modifiedAt" varchar not null,
"isFavorite" boolean default false not null,
"mimeType" varchar,
duration varchar,
constraint "UQ_b599ab0bd9574958acb0b30a90e"
unique ("deviceAssetId", "userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table assets`);
}
}
@@ -1,42 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTable1645130817965 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists exif
(
id serial
constraint "PK_28663352d85078ad0046dafafaa"
primary key,
"assetId" uuid not null
constraint "REL_c0117fdbc50b917ef9067740c4"
unique
constraint "FK_c0117fdbc50b917ef9067740c44"
references assets
on delete cascade,
make varchar,
model varchar,
"imageName" varchar,
"exifImageWidth" integer,
"exifImageHeight" integer,
"fileSizeInByte" integer,
orientation varchar,
"dateTimeOriginal" timestamp with time zone,
"modifyDate" timestamp with time zone,
"lensModel" varchar,
"fNumber" double precision,
"focalLength" double precision,
iso integer,
"exposureTime" double precision,
latitude double precision,
longitude double precision
);
create unique index if not exists "IDX_c0117fdbc50b917ef9067740c4" on exif ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table exif`);
}
}
@@ -1,30 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSmartInfoTable1645130870184 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists smart_info
(
id serial
constraint "PK_0beace66440e9713f5c40470e46"
primary key,
"assetId" uuid not null
constraint "UQ_5e3753aadd956110bf3ec0244ac"
unique
constraint "FK_5e3753aadd956110bf3ec0244ac"
references assets
on delete cascade,
tags text[]
);
create unique index if not exists "IDX_5e3753aadd956110bf3ec0244a"
on smart_info ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
drop table smart_info;
`);
}
}
@@ -1,25 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddExifTextSearchColumn1646249209023 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '')
)
) STORED;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}
@@ -1,29 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRegionCityToExIf1646709533213 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ADD COLUMN if not exists city varchar;
ALTER TABLE exif
ADD COLUMN if not exists state varchar;
ALTER TABLE exif
ADD COLUMN if not exists country varchar;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN city;
ALTER TABLE exif
DROP COLUMN state;
ALTER TABLE exif
DROP COLUMN country;
`);
}
}
@@ -1,37 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocationToExifTextSearch1646710459852 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", '')
)
) STORED;
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}
@@ -1,18 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddObjectColumnToSmartInfo1648317474768 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
ADD COLUMN if not exists objects text[];
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
DROP COLUMN objects;
`);
}
}
@@ -1,70 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSharedAlbumAndRelatedTables1649643216111 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create shared_albums
await queryRunner.query(`
create table if not exists shared_albums
(
id uuid default uuid_generate_v4() not null
constraint "PK_7f71c7b5bc7c87b8f94c9a93a00"
primary key,
"ownerId" varchar not null,
"albumName" varchar default 'Untitled Album'::character varying not null,
"createdAt" timestamp with time zone default now() not null,
"albumThumbnailAssetId" varchar
);
comment on column shared_albums."albumThumbnailAssetId" is 'Asset ID to be used as thumbnail';
`);
// Create user_shared_album
await queryRunner.query(`
create table if not exists user_shared_album
(
id serial
constraint "PK_b6562316a98845a7b3e9a25cdd0"
primary key,
"albumId" uuid not null
constraint "FK_7b3bf0f5f8da59af30519c25f18"
references shared_albums
on delete cascade,
"sharedUserId" uuid not null
constraint "FK_543c31211653e63e080ba882eb5"
references users,
constraint "PK_unique_user_in_album"
unique ("albumId", "sharedUserId")
);
`);
// Create asset_shared_album
await queryRunner.query(
`
create table if not exists asset_shared_album
(
id serial
constraint "PK_a34e076afbc601d81938e2c2277"
primary key,
"albumId" uuid not null
constraint "FK_a8b79a84996cef6ba6a3662825d"
references shared_albums
on delete cascade,
"assetId" uuid not null
constraint "FK_64f2e7d68d1d1d8417acc844a4a"
references assets
on delete cascade,
constraint "UQ_a1e2734a1ce361e7a26f6b28288"
unique ("albumId", "assetId")
);
`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
drop table asset_shared_album;
drop table user_shared_album;
drop table shared_albums;
`);
}
}
@@ -1,36 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
add column if not exists "firstName" varchar default '';
alter table users
add column if not exists "lastName" varchar default '';
alter table users
add column if not exists "profileImagePath" varchar default '';
alter table users
add column if not exists "isAdmin" bool default false;
alter table users
add column if not exists "isFirstLoggedIn" bool default true;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
drop column "firstName";
alter table users
drop column "lastName";
alter table users
drop column "isAdmin";
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithWebpPath1653214255670 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
add column if not exists "webpPath" varchar default '';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
drop column if exists "webpPath";
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
add column if not exists "encodedVideoPath" varchar default '';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
drop column if exists "encodedVideoPath";
`);
}
}
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameSharedAlbums1655401127251 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE shared_albums RENAME TO albums;
ALTER TABLE asset_shared_album RENAME TO asset_album;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_album RENAME TO asset_shared_album;
ALTER TABLE albums RENAME TO shared_albums;
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameIsFirstLoggedInColumn1656338626260 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE users
RENAME COLUMN "isFirstLoggedIn" to "shouldChangePassword";
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE users
RENAME COLUMN "shouldChangePassword" to "isFirstLoggedIn";
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
await queryRunner.query(
`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
await queryRunner.query(
`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`,
);
}
}
@@ -1,32 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DropExifTextSearchableColumns1656888918620 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", '')
)
) STORED;
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
}
@@ -1,53 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", ''))) STORED`);
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}
@@ -1,22 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddExifImageNameAsSearchableText1658860470248 implements MigrationInterface {
name = 'AddExifImageNameAsSearchableText1658860470248';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("imageName", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", ''))) STORED`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector NOT NULL`);
}
}
@@ -1,15 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCaption1661011331242 implements MigrationInterface {
name = 'AddCaption1661011331242';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "description" text DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "exif" ADD "fps" double precision`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "fps"`);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "description"`);
}
}
@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ChangeExifFileSizeInByteToBigInt1661528919411 implements MigrationInterface {
name = 'ChangeExifFileSizeInByteToBigInt1661528919411';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ALTER COLUMN "fileSizeInByte" type bigint using "fileSizeInByte"::bigint;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ALTER COLUMN "fileSizeInByte" type integer using "fileSizeInByte"::integer;
`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetChecksum1661881837496 implements MigrationInterface {
name = 'AddAssetChecksum1661881837496';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "checksum" bytea`);
await queryRunner.query(
`CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE 'checksum' IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_64c507300988dd1764f9a6530c"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`);
}
}
@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_userid_checksum" UNIQUE ("userId", "checksum")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`);
await queryRunner.query(
`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`,
);
}
}
@@ -1,21 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixTimestampDataTypeInAssetTable1662427365521 implements MigrationInterface {
name = 'FixTimestampDataTypeInAssetTable1662427365521';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
await queryRunner.query(
`ALTER TABLE "assets" ALTER COLUMN "createdAt" TYPE timestamptz USING "createdAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "assets" ALTER COLUMN "modifiedAt" TYPE timestamptz USING "createdAt"::timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "createdAt" TYPE varchar USING "createdAt"::varchar`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "modifiedAt" TYPE varchar USING "createdAt"::varchar`);
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" DROP NOT NULL`);
}
}
@@ -1,15 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSystemConfigTable1665540663419 implements MigrationInterface {
name = 'CreateSystemConfigTable1665540663419';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" character varying, CONSTRAINT "PK_aab69295b445016f56731f4d535" PRIMARY KEY ("key"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "system_config"`);
}
}
@@ -1,13 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddingDeletedAtColumnInUserEntity1667762360744 implements MigrationInterface {
name = 'AddingDeletedAtColumnInUserEntity1667762360744';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "deletedAt" TIMESTAMP`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "deletedAt"`);
}
}
@@ -1,16 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddLivePhotosRelatedColumnToAssetTable1668383120461 implements MigrationInterface {
name = 'AddLivePhotosRelatedColumnToAssetTable1668383120461'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "isVisible" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "assets" ADD "livePhotoVideoId" uuid`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "livePhotoVideoId"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isVisible"`);
}
}
@@ -1,16 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateUserTableForOIDC1668835311083 implements MigrationInterface {
name = 'UpdateUserTableForOIDC1668835311083'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "password" SET DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "salt" SET DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "salt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "password" DROP DEFAULT`);
}
}
@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class OAuthId1670104716264 implements MigrationInterface {
name = 'OAuthId1670104716264'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "oauthId" character varying NOT NULL DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "oauthId"`);
}
}
@@ -1,26 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTagsTable1670257571385 implements MigrationInterface {
name = 'CreateTagsTable1670257571385'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "name" character varying NOT NULL, "userId" uuid NOT NULL, "renameTagId" uuid, CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId"), CONSTRAINT "PK_e7dc17249a1148a1970748eda99" PRIMARY KEY ("id")); COMMENT ON COLUMN "tags"."renameTagId" IS 'The new renamed tagId'`);
await queryRunner.query(`CREATE TABLE "tag_asset" ("assetsId" uuid NOT NULL, "tagsId" uuid NOT NULL, CONSTRAINT "PK_ef5346fe522b5fb3bc96454747e" PRIMARY KEY ("assetsId", "tagsId"))`);
await queryRunner.query(`CREATE INDEX "IDX_f8e8a9e893cb5c54907f1b798e" ON "tag_asset" ("assetsId") `);
await queryRunner.query(`CREATE INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4" ON "tag_asset" ("tagsId") `);
await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`);
await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`);
await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
await queryRunner.query(`DROP INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4"`);
await queryRunner.query(`DROP INDEX "IDX_f8e8a9e893cb5c54907f1b798e"`);
await queryRunner.query(`DROP TABLE "tag_asset"`);
await queryRunner.query(`DROP TABLE "tags"`);
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TruncateOldConfigItems1670607437008 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "system_config"`);
}
public async down(): Promise<void> {
// noop
}
}
@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserEmailUniqueConstraint1670633210032 implements MigrationInterface {
name = 'AddUserEmailUniqueConstraint1670633210032'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3"`);
}
}
@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DropSaltColumn1672109862870 implements MigrationInterface {
name = 'DropSaltColumn1672109862870'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "salt"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "salt" character varying NOT NULL DEFAULT ''`);
}
}
@@ -1,16 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAPIKeys1672502270115 implements MigrationInterface {
name = 'AddAPIKeys1672502270115'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "api_keys" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "key" character varying NOT NULL, "userId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "api_keys" ADD CONSTRAINT "FK_6c2e267ae764a9413b863a29342" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "api_keys" DROP CONSTRAINT "FK_6c2e267ae764a9413b863a29342"`);
await queryRunner.query(`DROP TABLE "api_keys"`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSharedLinkTable1673150490490 implements MigrationInterface {
name = 'AddSharedLinkTable1673150490490'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" character varying NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key"), CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_sharedlink_key" ON "shared_links" ("key") `);
await queryRunner.query(`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL, CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId"))`);
await queryRunner.query(`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId") `);
await queryRunner.query(`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId") `);
await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`);
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`);
await queryRunner.query(`DROP INDEX "IDX_c9fab4aa97ffd1b034f3d6581a"`);
await queryRunner.query(`DROP INDEX "IDX_5b7decce6c8d3db9593d6111a6"`);
await queryRunner.query(`DROP TABLE "shared_link__asset"`);
await queryRunner.query(`DROP INDEX "IDX_sharedlink_key"`);
await queryRunner.query(`DROP TABLE "shared_links"`);
}
}
@@ -1,15 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMorePermissionToSharedLink1673907194740 implements MigrationInterface {
name = 'AddMorePermissionToSharedLink1673907194740';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" ADD "allowDownload" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "shared_links" ADD "showExif" boolean NOT NULL DEFAULT true`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "showExif"`);
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "allowDownload"`);
}
}

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