Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf
This commit is contained in:
@@ -29,6 +29,8 @@ export interface IAssetRepository {
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity>;
|
||||
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||
getAll(): Promise<AssetEntity[]>;
|
||||
getAllVideos(): Promise<AssetEntity[]>;
|
||||
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
|
||||
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
||||
getById(assetId: string): Promise<AssetEntity>;
|
||||
@@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository {
|
||||
@Inject(ITagRepository) private _tagRepository: ITagRepository,
|
||||
) {}
|
||||
|
||||
async getAllVideos(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository.find({
|
||||
where: { type: AssetType.VIDEO },
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository.find({
|
||||
where: { isVisible: true },
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
smartInfo: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { Response as Res } from 'express';
|
||||
@@ -80,7 +80,7 @@ export class AssetController {
|
||||
})
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
|
||||
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
|
||||
@@ -123,6 +123,8 @@ describe('AssetService', () => {
|
||||
assetRepositoryMock = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
getAllVideos: jest.fn(),
|
||||
getAllByUserId: jest.fn(),
|
||||
getAllByDeviceId: jest.fn(),
|
||||
getAssetCountByTimeBucket: jest.fn(),
|
||||
|
||||
@@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -82,16 +83,16 @@ export class AssetService {
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
res: Res,
|
||||
originalAssetData: Express.Multer.File,
|
||||
livePhotoAssetData?: Express.Multer.File,
|
||||
originalAssetData: ImmichFile,
|
||||
livePhotoAssetData?: ImmichFile,
|
||||
) {
|
||||
const checksum = await this.calculateChecksum(originalAssetData.path);
|
||||
const checksum = originalAssetData.checksum;
|
||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
||||
|
||||
try {
|
||||
if (isLivePhoto) {
|
||||
const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path);
|
||||
const livePhotoChecksum = livePhotoAssetData.checksum;
|
||||
livePhotoAssetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
|
||||
@@ -19,8 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
|
||||
const user = await this.authService.validateSocket(client);
|
||||
const user = await this.authService.validate(client.request.headers);
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
} else {
|
||||
@@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
client.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection');
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Patch, Post, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
@@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly deviceInfoService: DeviceInfoService) {}
|
||||
|
||||
/** @deprecated */
|
||||
@Post()
|
||||
public async createDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
return this.upsertDeviceInfo(user, dto);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
@Patch()
|
||||
public async updateDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
return this.upsertDeviceInfo(user, dto);
|
||||
}
|
||||
|
||||
@Put()
|
||||
public async upsertDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class JobCommandDto {
|
||||
@IsNotEmpty()
|
||||
@@ -9,4 +9,8 @@ export class JobCommandDto {
|
||||
enumName: 'JobCommand',
|
||||
})
|
||||
command!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeAllAssets!: boolean;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ export class JobController {
|
||||
@Put('/:jobId')
|
||||
async sendJobCommand(
|
||||
@Param(ValidationPipe) params: GetJobDto,
|
||||
@Body(ValidationPipe) body: JobCommandDto,
|
||||
@Body(ValidationPipe) dto: JobCommandDto,
|
||||
): Promise<number> {
|
||||
if (body.command === 'start') {
|
||||
return await this.jobService.start(params.jobId);
|
||||
if (dto.command === 'start') {
|
||||
return await this.jobService.start(params.jobId, dto.includeAllAssets);
|
||||
}
|
||||
if (body.command === 'stop') {
|
||||
if (dto.command === 'stop') {
|
||||
return await this.jobService.stop(params.jobId);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository';
|
||||
import { AssetType } from '@app/infra';
|
||||
import { JobId } from './dto/get-job.dto';
|
||||
import { MACHINE_LEARNING_ENABLED } from '@app/common';
|
||||
|
||||
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
|
||||
const jobIds = Object.values(JobId) as JobId[];
|
||||
|
||||
@Injectable()
|
||||
@@ -19,8 +19,8 @@ export class JobService {
|
||||
}
|
||||
}
|
||||
|
||||
start(jobId: JobId): Promise<number> {
|
||||
return this.run(this.asQueueName(jobId));
|
||||
start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
|
||||
return this.run(this.asQueueName(jobId), includeAllAssets);
|
||||
}
|
||||
|
||||
async stop(jobId: JobId): Promise<number> {
|
||||
@@ -36,7 +36,7 @@ export class JobService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private async run(name: QueueName): Promise<number> {
|
||||
private async run(name: QueueName, includeAllAssets: boolean): Promise<number> {
|
||||
const isActive = await this.jobRepository.isActive(name);
|
||||
if (isActive) {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
@@ -44,7 +44,9 @@ export class JobService {
|
||||
|
||||
switch (name) {
|
||||
case QueueName.VIDEO_CONVERSION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoEncodedVideo();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAllVideos()
|
||||
: await this._assetRepository.getAssetWithNoEncodedVideo();
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
}
|
||||
@@ -61,7 +63,10 @@ export class JobService {
|
||||
throw new BadRequestException('Machine learning is not enabled.');
|
||||
}
|
||||
|
||||
const assets = await this._assetRepository.getAssetWithNoSmartInfo();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoSmartInfo();
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } });
|
||||
await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } });
|
||||
@@ -70,19 +75,37 @@ export class JobService {
|
||||
}
|
||||
|
||||
case QueueName.METADATA_EXTRACTION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoEXIF();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoEXIF();
|
||||
|
||||
for (const asset of assets) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
|
||||
await this.jobRepository.add({
|
||||
name: JobName.EXTRACT_VIDEO_METADATA,
|
||||
data: {
|
||||
asset,
|
||||
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
|
||||
await this.jobRepository.add({
|
||||
name: JobName.EXIF_EXTRACTION,
|
||||
data: {
|
||||
asset,
|
||||
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return assets.length;
|
||||
}
|
||||
|
||||
case QueueName.THUMBNAIL_GENERATION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoThumbnail();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoThumbnail();
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
@@ -23,6 +22,9 @@ import {
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
|
||||
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
|
||||
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,8 +36,6 @@ import {
|
||||
|
||||
AssetModule,
|
||||
|
||||
ImmichJwtModule,
|
||||
|
||||
DeviceInfoModule,
|
||||
|
||||
ServerInfoModule,
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
],
|
||||
providers: [],
|
||||
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
// TODO: check if consumer is needed or remove
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { Request } from 'express';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { diskStorage, StorageEngine } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AuthUserDto } from '../decorators/auth-user.decorator';
|
||||
@@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util';
|
||||
|
||||
const logger = new Logger('AssetUploadConfig');
|
||||
|
||||
export interface ImmichFile extends Express.Multer.File {
|
||||
/** sha1 hash of file */
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export const assetUploadOption: MulterOptions = {
|
||||
fileFilter,
|
||||
storage: diskStorage({
|
||||
destination,
|
||||
filename,
|
||||
}),
|
||||
storage: customStorage(),
|
||||
};
|
||||
|
||||
export function customStorage(): StorageEngine {
|
||||
const storage = diskStorage({ destination, filename });
|
||||
|
||||
return {
|
||||
_handleFile(req, file, callback) {
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
|
||||
storage._handleFile(req, file, (error, response) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...response, checksum: hash.digest() } as ImmichFile);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_removeFile(req, file, callback) {
|
||||
storage._removeFile(req, file, callback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const multerUtils = { fileFilter, filename, destination };
|
||||
|
||||
function fileFilter(req: Request, file: any, cb: any) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
|
||||
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
|
||||
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
|
||||
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
|
||||
|
||||
interface AuthenticatedOptions {
|
||||
admin?: boolean;
|
||||
|
||||
Vendored
+3
@@ -4,5 +4,8 @@ declare global {
|
||||
namespace Express {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface User extends AuthUserDto {}
|
||||
export interface Request {
|
||||
user: AuthUserDto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,6 @@ async function bootstrap() {
|
||||
.addBearerAuth({
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
bearerFormat: 'JWT',
|
||||
name: 'JWT',
|
||||
description: 'Enter JWT token',
|
||||
in: 'header',
|
||||
})
|
||||
.addServer('/api')
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
|
||||
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
|
||||
import { JWT_STRATEGY } from '../strategies/jwt.strategy';
|
||||
import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy';
|
||||
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { AuthService, AuthUserDto, UserService } from '@app/domain';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import { Request } from 'express';
|
||||
|
||||
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
|
||||
constructor(private userService: UserService, private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(request: Request): Promise<AuthUserDto> {
|
||||
const authUser = await this.authService.validate(request.headers);
|
||||
|
||||
if (!authUser) {
|
||||
throw new UnauthorizedException('Incorrect token provided');
|
||||
}
|
||||
|
||||
return authUser;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APIKeyStrategy } from './strategies/api-key.strategy';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { PublicShareStrategy } from './strategies/public-share.strategy';
|
||||
|
||||
@Module({
|
||||
providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
})
|
||||
export class ImmichJwtModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
|
||||
|
||||
export const JWT_STRATEGY = 'jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(req) => authService.extractJwtFromCookie(req.cookies),
|
||||
(req) => authService.extractJwtFromHeader(req.headers),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
|
||||
return this.authService.validatePayload(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { userUtils } from '@app/common';
|
||||
import { IJobRepository, JobName } from '@app/domain';
|
||||
|
||||
@@ -13,93 +12,8 @@ export class ScheduleTasksService {
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(ExifEntity)
|
||||
private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async webpConversion() {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
webpPath: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (assets.length == 0) {
|
||||
Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||
async videoConversion() {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
type: AssetType.VIDEO,
|
||||
mimeType: 'video/quicktime',
|
||||
encodedVideoPath: '',
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
async reverseGeocoding() {
|
||||
const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
|
||||
|
||||
if (isGeocodingEnabled) {
|
||||
const exifInfo = await this.exifRepository.find({
|
||||
where: {
|
||||
city: IsNull(),
|
||||
longitude: Not(IsNull()),
|
||||
latitude: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
for (const exif of exifInfo) {
|
||||
await this.jobRepository.add({
|
||||
name: JobName.REVERSE_GEOCODING,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||
async extractExif() {
|
||||
const exifAssets = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.leftJoinAndSelect('asset.exifInfo', 'ei')
|
||||
.where('ei."assetId" IS NULL')
|
||||
.getMany();
|
||||
|
||||
for (const asset of exifAssets) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
|
||||
} else {
|
||||
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_11PM)
|
||||
async deleteUserAndRelatedAssets() {
|
||||
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthService, DomainModule, UserService } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
||||
return request(app.getHttpServer()).post('/album').send(data);
|
||||
@@ -21,7 +21,7 @@ describe('Album', () => {
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule],
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"modulePaths": ["<rootDir>", "<rootDir>../../../"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthGuard } from '../src/modules/immich-jwt/guards/auth.guard';
|
||||
import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard';
|
||||
|
||||
type CustomAuthCallback = () => AuthUserDto;
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { clearDb, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
|
||||
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserController } from '../src/controllers';
|
||||
import { AuthService } from '@app/domain';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
function _createUser(userService: UserService, data: CreateUserDto) {
|
||||
return userService.createUser(data);
|
||||
@@ -25,7 +25,7 @@ describe('User', () => {
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), ImmichJwtModule],
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
controllers: [UserController],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -154,13 +154,6 @@ export class MetadataExtractionProcessor {
|
||||
return exifDate.toDate();
|
||||
};
|
||||
|
||||
const getExposureTimeDenominator = (exposureTime: string | undefined) => {
|
||||
if (!exposureTime) return null;
|
||||
|
||||
const exposureTimeSplit = exposureTime.split('/');
|
||||
return exposureTimeSplit.length === 2 ? parseInt(exposureTimeSplit[1]) : null;
|
||||
};
|
||||
|
||||
const createdAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.createdAt);
|
||||
const modifyDate = exifToDate(exifData?.ModifyDate ?? asset.modifiedAt);
|
||||
const fileStats = fs.statSync(asset.originalPath);
|
||||
@@ -174,7 +167,7 @@ export class MetadataExtractionProcessor {
|
||||
newExif.model = exifData?.Model || null;
|
||||
newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null;
|
||||
newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null;
|
||||
newExif.exposureTime = getExposureTimeDenominator(exifData?.ExposureTime);
|
||||
newExif.exposureTime = exifData?.ExposureTime || null;
|
||||
newExif.orientation = exifData?.Orientation?.toString() || null;
|
||||
newExif.dateTimeOriginal = createdAt;
|
||||
newExif.modifyDate = modifyDate;
|
||||
@@ -223,7 +216,7 @@ export class MetadataExtractionProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
await this.exifRepository.save(newExif);
|
||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error extracting EXIF ${error}`, error?.stack);
|
||||
}
|
||||
@@ -334,7 +327,7 @@ export class MetadataExtractionProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
await this.exifRepository.save(newExif);
|
||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
|
||||
} catch (err) {
|
||||
// do nothing
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
@Processor(QueueName.VIDEO_CONVERSION)
|
||||
export class VideoTranscodeProcessor {
|
||||
readonly logger = new Logger(VideoTranscodeProcessor.name);
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
@@ -20,7 +21,6 @@ export class VideoTranscodeProcessor {
|
||||
@Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
|
||||
async videoConversion(job: Job<IVideoConversionProcessor>) {
|
||||
const { asset } = job.data;
|
||||
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
|
||||
|
||||
@@ -30,17 +30,14 @@ export class VideoTranscodeProcessor {
|
||||
|
||||
const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`;
|
||||
|
||||
if (!asset.encodedVideoPath) {
|
||||
// Put the processing into its own async function to prevent the job exist right away
|
||||
await this.runVideoEncode(asset, savedEncodedPath);
|
||||
}
|
||||
await this.runVideoEncode(asset, savedEncodedPath);
|
||||
}
|
||||
|
||||
async runFFProbePipeline(asset: AssetEntity): Promise<FfprobeData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||
if (err || !data) {
|
||||
Logger.error(`Cannot probe video ${err}`, 'mp4Conversion');
|
||||
this.logger.error(`Cannot probe video ${err}`, 'runFFProbePipeline');
|
||||
reject(err);
|
||||
}
|
||||
|
||||
@@ -88,14 +85,14 @@ export class VideoTranscodeProcessor {
|
||||
])
|
||||
.output(savedEncodedPath)
|
||||
.on('start', () => {
|
||||
Logger.log('Start Converting Video', 'mp4Conversion');
|
||||
this.logger.log('Start Converting Video');
|
||||
})
|
||||
.on('error', (error) => {
|
||||
Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion');
|
||||
this.logger.error(`Cannot Convert Video ${error}`);
|
||||
reject();
|
||||
})
|
||||
.on('end', async () => {
|
||||
Logger.log(`Converting Success ${asset.id}`, 'mp4Conversion');
|
||||
this.logger.log(`Converting Success ${asset.id}`);
|
||||
await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath });
|
||||
resolve();
|
||||
})
|
||||
|
||||
@@ -2506,78 +2506,6 @@
|
||||
}
|
||||
},
|
||||
"/device-info": {
|
||||
"post": {
|
||||
"operationId": "createDeviceInfo",
|
||||
"description": "@deprecated",
|
||||
"deprecated": true,
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "updateDeviceInfo",
|
||||
"description": "@deprecated",
|
||||
"deprecated": true,
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "upsertDeviceInfo",
|
||||
"description": "",
|
||||
@@ -2794,8 +2722,6 @@
|
||||
"scheme": "Bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"type": "http",
|
||||
"name": "JWT",
|
||||
"description": "Enter JWT token",
|
||||
"in": "header"
|
||||
}
|
||||
},
|
||||
@@ -3207,7 +3133,7 @@
|
||||
"default": null
|
||||
},
|
||||
"exposureTime": {
|
||||
"type": "number",
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null
|
||||
},
|
||||
@@ -4610,10 +4536,14 @@
|
||||
"properties": {
|
||||
"command": {
|
||||
"$ref": "#/components/schemas/JobCommand"
|
||||
},
|
||||
"includeAllAssets": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
"command",
|
||||
"includeAllAssets"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigModuleOptions } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
import { createSecretKey, generateKeySync } from 'node:crypto';
|
||||
|
||||
const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
|
||||
const key = createSecretKey(value, 'base64');
|
||||
const keySizeBits = (key.symmetricKeySize ?? 0) * 8;
|
||||
|
||||
if (keySizeBits < 128) {
|
||||
const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64');
|
||||
Logger.warn('The current JWT_SECRET key is insecure. It should be at least 128 bits long!');
|
||||
Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const WHEN_DB_URL_SET = Joi.when('DB_URL', {
|
||||
is: Joi.exist(),
|
||||
@@ -31,7 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||
DB_PASSWORD: WHEN_DB_URL_SET,
|
||||
DB_DATABASE_NAME: WHEN_DB_URL_SET,
|
||||
DB_URL: Joi.string().optional(),
|
||||
JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
|
||||
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
|
||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IKeyRepository {
|
||||
* Includes the hashed `key` for verification
|
||||
* @param id
|
||||
*/
|
||||
getKey(id: number): Promise<APIKeyEntity | null>;
|
||||
getKey(hashedToken: string): Promise<APIKeyEntity | null>;
|
||||
getById(userId: string, id: number): Promise<APIKeyEntity | null>;
|
||||
getByUserId(userId: string): Promise<APIKeyEntity[]>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APIKeyEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
@@ -10,10 +10,10 @@ const adminKey = Object.freeze({
|
||||
name: 'My Key',
|
||||
key: 'my-api-key (hashed)',
|
||||
userId: authStub.admin.id,
|
||||
user: entityStub.admin,
|
||||
user: userEntityStub.admin,
|
||||
} as APIKeyEntity);
|
||||
|
||||
const token = Buffer.from('1:my-api-key', 'utf8').toString('base64');
|
||||
const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
@@ -38,7 +38,7 @@ describe(APIKeyService.name, () => {
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hash).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
@@ -52,7 +52,7 @@ describe(APIKeyService.name, () => {
|
||||
userId: authStub.admin.id,
|
||||
});
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hash).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,8 +126,7 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith(1);
|
||||
expect(cryptoMock.compareSync).not.toHaveBeenCalled();
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
|
||||
it('should validate the token', async () => {
|
||||
@@ -135,8 +134,7 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
await expect(sut.validate(token)).resolves.toEqual(authStub.admin);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith(1);
|
||||
expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)');
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
@@ -14,15 +13,13 @@ export class APIKeyService {
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, '');
|
||||
const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||
const entity = await this.repository.create({
|
||||
key: await this.crypto.hash(key, 10),
|
||||
key: this.crypto.hashSha256(secret),
|
||||
name: dto.name || 'API Key',
|
||||
userId: authUser.id,
|
||||
});
|
||||
|
||||
const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64');
|
||||
|
||||
return { secret, apiKey: mapKey(entity) };
|
||||
}
|
||||
|
||||
@@ -60,22 +57,18 @@ export class APIKeyService {
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto> {
|
||||
const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':');
|
||||
const id = Number(_id);
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
if (id && key) {
|
||||
const entity = await this.repository.getKey(id);
|
||||
if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) {
|
||||
const user = entity.user as UserEntity;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API Key');
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ExifResponseDto {
|
||||
fNumber?: number | null = null;
|
||||
focalLength?: number | null = null;
|
||||
iso?: number | null = null;
|
||||
exposureTime?: number | null = null;
|
||||
exposureTime?: string | null = null;
|
||||
latitude?: number | null = null;
|
||||
longitude?: number | null = null;
|
||||
city?: string | null = null;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { jwtSecret } from './auth.constant';
|
||||
|
||||
export const jwtConfig: JwtModuleOptions = {
|
||||
secret: jwtSecret,
|
||||
signOptions: { expiresIn: '30d' },
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export const jwtSecret = process.env.JWT_SECRET;
|
||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export enum AuthType {
|
||||
|
||||
@@ -4,8 +4,9 @@ import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain';
|
||||
import cookieParser from 'cookie';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
@@ -13,11 +14,14 @@ export type JwtValidationResult = {
|
||||
};
|
||||
|
||||
export class AuthCore {
|
||||
private userTokenCore: UserTokenCore;
|
||||
constructor(
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
configRepository: ISystemConfigRepository,
|
||||
userTokenRepository: IUserTokenRepository,
|
||||
private config: SystemConfig,
|
||||
) {
|
||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
||||
const configCore = new SystemConfigCore(configRepository);
|
||||
configCore.config$.subscribe((config) => (this.config = config));
|
||||
}
|
||||
@@ -33,8 +37,8 @@ export class AuthCore {
|
||||
let accessTokenCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
} else {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
@@ -42,9 +46,8 @@ export class AuthCore {
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
|
||||
public createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const payload: JwtPayloadDto = { userId: user.id, email: user.email };
|
||||
const accessToken = this.generateToken(payload);
|
||||
public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const accessToken = await this.userTokenCore.createToken(user);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, isSecure);
|
||||
return { response, cookie };
|
||||
@@ -54,12 +57,12 @@ export class AuthCore {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return this.cryptoRepository.compareSync(inputPassword, user.password);
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return null;
|
||||
return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || ''));
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
@@ -70,11 +73,7 @@ export class AuthCore {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
extractTokenFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
|
||||
private generateToken(payload: JwtPayloadDto) {
|
||||
return this.cryptoRepository.signJwt({ ...payload });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
@@ -17,6 +17,9 @@ import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.
|
||||
import { AuthService } from './auth.service';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { SignUpDto } from './dto';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -47,6 +50,7 @@ describe('AuthService', () => {
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
@@ -76,8 +80,9 @@ describe('AuthService', () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, config);
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
@@ -106,13 +111,15 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate the cookie headers (insecure)', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -131,7 +138,7 @@ describe('AuthService', () => {
|
||||
await sut.changePassword(authUser, dto);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
|
||||
expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
|
||||
});
|
||||
|
||||
it('should throw when auth user email is not found', async () => {
|
||||
@@ -147,7 +154,7 @@ describe('AuthService', () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
cryptoMock.compareBcrypt.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
@@ -161,8 +168,6 @@ describe('AuthService', () => {
|
||||
const authUser = { email: 'test@imimch.com' } as UserEntity;
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
cryptoMock.compareSync.mockReturnValue(false);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue({
|
||||
email: 'test@immich.com',
|
||||
password: '',
|
||||
@@ -212,52 +217,64 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSocket', () => {
|
||||
describe('validate - socket connections', () => {
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.user1);
|
||||
const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } };
|
||||
await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers)).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePayload', () => {
|
||||
describe('validate - api request', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin);
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(
|
||||
sut.validate({ cookie: 'immich_access_token=auth_token', email: 'a', userId: 'test' }),
|
||||
).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromCookie', () => {
|
||||
describe('extractTokenFromHeader - Cookie', () => {
|
||||
it('should extract the access token', () => {
|
||||
const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' };
|
||||
expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt');
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
expect(sut.extractJwtFromCookie(undefined as any)).toBeNull();
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: undefined,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
expect(sut.extractJwtFromCookie({})).toBeNull();
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: '',
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJwtFromHeader', () => {
|
||||
describe('extractTokenFromHeader - Bearer Auth', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractJwtFromHeader({})).toBeNull();
|
||||
expect(sut.extractTokenFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,20 +7,20 @@ import {
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import * as cookieParser from 'cookie';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { Socket } from 'socket.io';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { AuthType, jwtSecret } from './auth.constant';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private userTokenCore: UserTokenCore;
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
@@ -31,11 +31,14 @@ export class AuthService {
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -49,7 +52,7 @@ export class AuthService {
|
||||
|
||||
let user = await this.userCore.getByEmail(loginCredential.email, true);
|
||||
if (user) {
|
||||
const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user);
|
||||
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const valid = await this.authCore.validatePassword(password, user);
|
||||
const valid = this.authCore.validatePassword(password, user);
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Wrong password');
|
||||
}
|
||||
@@ -112,49 +115,28 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async validateSocket(client: Socket): Promise<UserResponseDto | null> {
|
||||
try {
|
||||
const headers = client.handshake.headers;
|
||||
const accessToken =
|
||||
this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers);
|
||||
|
||||
if (accessToken) {
|
||||
const payload = await this.cryptoRepository.verifyJwtAsync<JwtPayloadDto>(accessToken, { secret: jwtSecret });
|
||||
if (payload?.userId && payload?.email) {
|
||||
const user = await this.userCore.get(payload.userId);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async validatePayload(payload: JwtPayloadDto) {
|
||||
const { userId } = payload;
|
||||
const user = await this.userCore.get(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Failure to validate JWT payload');
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto> {
|
||||
const tokenValue = this.extractTokenFromHeader(headers);
|
||||
if (!tokenValue) {
|
||||
throw new UnauthorizedException('No access token provided in request');
|
||||
}
|
||||
|
||||
const authUser = new AuthUserDto();
|
||||
authUser.id = user.id;
|
||||
authUser.email = user.email;
|
||||
authUser.isAdmin = user.isAdmin;
|
||||
authUser.isPublicUser = false;
|
||||
authUser.isAllowUpload = true;
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
const user = await this.userTokenCore.getUserByToken(hashedToken);
|
||||
if (user) {
|
||||
return {
|
||||
...user,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
};
|
||||
}
|
||||
|
||||
return authUser;
|
||||
throw new UnauthorizedException('Invalid access token provided');
|
||||
}
|
||||
|
||||
extractJwtFromCookie(cookies: Record<string, string>) {
|
||||
return this.authCore.extractJwtFromCookie(cookies);
|
||||
}
|
||||
|
||||
extractJwtFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractJwtFromHeader(headers);
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractTokenFromHeader(headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareSync(data: Buffer | string, encrypted: string): boolean;
|
||||
signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string;
|
||||
verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T>;
|
||||
hashSha256(data: string): string;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './auth.config';
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
|
||||
@@ -13,7 +13,6 @@ const providers: Provider[] = [
|
||||
SystemConfigService,
|
||||
UserService,
|
||||
ShareService,
|
||||
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
inject: [SystemConfigService],
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './share';
|
||||
export * from './system-config';
|
||||
export * from './tag';
|
||||
export * from './user';
|
||||
export * from './user-token';
|
||||
|
||||
@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { OAuthService } from '../oauth';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -35,6 +38,7 @@ describe('OAuthService', () => {
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => OAuthService;
|
||||
|
||||
@@ -60,8 +64,9 @@ describe('OAuthService', () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, config);
|
||||
create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
|
||||
sut = create(systemConfigStub.disabled);
|
||||
});
|
||||
@@ -106,23 +111,25 @@ describe('OAuthService', () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub });
|
||||
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getAdmin.mockResolvedValue(entityStub.user1);
|
||||
userMock.create.mockResolvedValue(entityStub.user1);
|
||||
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
|
||||
userMock.create.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
@@ -135,7 +142,8 @@ describe('OAuthService', () => {
|
||||
it('should use the mobile redirect override', async () => {
|
||||
sut = create(systemConfigStub.override);
|
||||
|
||||
userMock.getByOAuthId.mockResolvedValue(entityStub.user1);
|
||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, true);
|
||||
|
||||
@@ -147,7 +155,7 @@ describe('OAuthService', () => {
|
||||
it('should link an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
|
||||
|
||||
@@ -171,7 +179,7 @@ describe('OAuthService', () => {
|
||||
it('should unlink an account', async () => {
|
||||
sut = create(systemConfigStub.enabled);
|
||||
|
||||
userMock.update.mockResolvedValue(entityStub.user1);
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
|
||||
await sut.unlink(authStub.user1);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
|
||||
import { OAuthCore } from './oauth.core';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
@@ -20,10 +21,11 @@ export class OAuthService {
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
|
||||
) {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
authStub,
|
||||
entityStub,
|
||||
userEntityStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
@@ -50,7 +50,7 @@ describe(ShareService.name, () => {
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(entityStub.admin);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ShareService {
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
) {
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
this.userCore = new UserCore(userRepository);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './user-token.repository';
|
||||
export * from './user-token.core';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IUserTokenRepository } from './user-token.repository';
|
||||
|
||||
@Injectable()
|
||||
export class UserTokenCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
|
||||
|
||||
public async getUserByToken(tokenValue: string): Promise<UserEntity | null> {
|
||||
const token = await this.repository.get(tokenValue);
|
||||
if (token?.user) {
|
||||
return token.user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async createToken(user: UserEntity): Promise<string> {
|
||||
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||
const token = this.crypto.hashSha256(key);
|
||||
await this.repository.create({
|
||||
token,
|
||||
user,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { UserTokenEntity } from '@app/infra/db/entities';
|
||||
|
||||
export const IUserTokenRepository = 'IUserTokenRepository';
|
||||
|
||||
export interface IUserTokenRepository {
|
||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
delete(userToken: string): Promise<void>;
|
||||
get(userToken: string): Promise<UserTokenEntity | null>;
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
import { hash } from 'bcrypt';
|
||||
import { constants, createReadStream, ReadStream } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto';
|
||||
import { IUserRepository, UserListFilter } from './user.repository';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export class UserCore {
|
||||
constructor(private userRepository: IUserRepository) {}
|
||||
constructor(private userRepository: IUserRepository, private cryptoRepository: ICryptoRepository) {}
|
||||
|
||||
async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
|
||||
if (!(authUser.isAdmin || authUser.id === id)) {
|
||||
@@ -37,7 +37,7 @@ export class UserCore {
|
||||
|
||||
try {
|
||||
if (dto.password) {
|
||||
dto.password = await hash(dto.password, SALT_ROUNDS);
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, dto);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { IUserRepository } from './user.repository';
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { when } from 'jest-when';
|
||||
import { newUserRepositoryMock } from '../../test';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@@ -77,10 +77,12 @@ const adminUserResponse = Object.freeze({
|
||||
describe(UserService.name, () => {
|
||||
let sut: UserService;
|
||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||
let cryptoRepositoryMock: jest.Mocked<ICryptoRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
userRepositoryMock = newUserRepositoryMock();
|
||||
sut = new UserService(userRepositoryMock);
|
||||
cryptoRepositoryMock = newCryptoRepositoryMock();
|
||||
sut = new UserService(userRepositoryMock, cryptoRepositoryMock);
|
||||
|
||||
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
|
||||
when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ReadStream } from 'fs';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IUserRepository } from '../user';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
@@ -17,8 +17,11 @@ import { UserCore } from './user.core';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private userCore: UserCore;
|
||||
constructor(@Inject(IUserRepository) userRepository: IUserRepository) {
|
||||
this.userCore = new UserCore(userRepository);
|
||||
constructor(
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { ICryptoRepository } from '../src';
|
||||
export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
|
||||
return {
|
||||
randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')),
|
||||
compareSync: jest.fn().mockReturnValue(true),
|
||||
hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
|
||||
signJwt: jest.fn().mockReturnValue('signed-jwt'),
|
||||
verifyJwtAsync: jest.fn().mockResolvedValue({ userId: 'test', email: 'test' }),
|
||||
compareBcrypt: jest.fn().mockReturnValue(true),
|
||||
hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
|
||||
hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import {
|
||||
AssetType,
|
||||
SharedLinkEntity,
|
||||
SharedLinkType,
|
||||
SystemConfig,
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
} from '@app/infra/db/entities';
|
||||
import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src';
|
||||
|
||||
const today = new Date();
|
||||
@@ -22,7 +29,7 @@ const assetInfo: ExifResponseDto = {
|
||||
fNumber: 100,
|
||||
focalLength: 100,
|
||||
iso: 100,
|
||||
exposureTime: 100,
|
||||
exposureTime: '1/16',
|
||||
latitude: 100,
|
||||
longitude: 100,
|
||||
city: 'city',
|
||||
@@ -81,6 +88,8 @@ export const authStub = {
|
||||
isAdmin: false,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
}),
|
||||
adminSharedLink: Object.freeze<AuthUserDto>({
|
||||
id: 'admin_id',
|
||||
@@ -104,7 +113,7 @@ export const authStub = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const entityStub = {
|
||||
export const userEntityStub = {
|
||||
admin: Object.freeze<UserEntity>({
|
||||
...authStub.admin,
|
||||
password: 'admin_password',
|
||||
@@ -129,6 +138,16 @@ export const entityStub = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const userTokenEntityStub = {
|
||||
userToken: Object.freeze<UserTokenEntity>({
|
||||
id: 'token-id',
|
||||
token: 'auth_token',
|
||||
user: userEntityStub.user1,
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
}),
|
||||
};
|
||||
|
||||
export const systemConfigStub = {
|
||||
defaults: Object.freeze({
|
||||
ffmpeg: {
|
||||
@@ -204,7 +223,7 @@ export const systemConfigStub = {
|
||||
export const loginResponseStub = {
|
||||
user1oauth: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
@@ -214,13 +233,13 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=oauth; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
user1password: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
@@ -230,13 +249,13 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
user1insecure: {
|
||||
response: {
|
||||
accessToken: 'signed-jwt',
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
@@ -246,7 +265,7 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=signed-jwt; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
],
|
||||
},
|
||||
@@ -349,7 +368,7 @@ export const sharedLinkStub = {
|
||||
fNumber: 100,
|
||||
focalLength: 100,
|
||||
iso: 100,
|
||||
exposureTime: 100,
|
||||
exposureTime: '1/16',
|
||||
fps: 100,
|
||||
asset: null as any,
|
||||
exifTextSearchableColumn: '',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IUserTokenRepository } from '../src';
|
||||
|
||||
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
|
||||
return {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
};
|
||||
};
|
||||
@@ -1,22 +1,16 @@
|
||||
import { ICryptoRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
import { compareSync, hash } from 'bcrypt';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class CryptoRepository implements ICryptoRepository {
|
||||
constructor(private jwtService: JwtService) {}
|
||||
|
||||
randomBytes = randomBytes;
|
||||
hash = hash;
|
||||
compareSync = compareSync;
|
||||
|
||||
signJwt(payload: string | Buffer | object) {
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
hashBcrypt = hash;
|
||||
compareBcrypt = compareSync;
|
||||
|
||||
verifyJwtAsync<T extends object = any>(token: string, options?: JwtVerifyOptions): Promise<T> {
|
||||
return this.jwtService.verifyAsync(token, options);
|
||||
hashSha256(value: string) {
|
||||
return createHash('sha256').update(value).digest('base64');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const baseDatabaseConfig: PostgresConnectionOptions = {
|
||||
const url = process.env.DB_URL;
|
||||
const urlOrParts = url
|
||||
? { url }
|
||||
: {
|
||||
host: process.env.DB_HOSTNAME || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
username: process.env.DB_USERNAME || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_DATABASE_NAME || 'immich',
|
||||
};
|
||||
|
||||
export const databaseConfig: PostgresConnectionOptions = {
|
||||
type: 'postgres',
|
||||
entities: [__dirname + '/../**/*.entity.{js,ts}'],
|
||||
synchronize: false,
|
||||
migrations: [__dirname + '/../migrations/*.{js,ts}'],
|
||||
migrationsRun: true,
|
||||
connectTimeoutMS: 10000, // 10 seconds
|
||||
...urlOrParts,
|
||||
};
|
||||
|
||||
const envBasedDatabaseConfig = {
|
||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
};
|
||||
|
||||
const url = process.env.DB_URL;
|
||||
const additionalSSLDatabaseConfig = url ? { url } : envBasedDatabaseConfig;
|
||||
|
||||
export const databaseConfig: PostgresConnectionOptions = { ...baseDatabaseConfig, ...additionalSSLDatabaseConfig };
|
||||
|
||||
export const dataSource = new DataSource(databaseConfig);
|
||||
|
||||
@@ -72,8 +72,8 @@ export class ExifEntity {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
iso!: number | null;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
exposureTime!: number | null;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
exposureTime!: string | null;
|
||||
|
||||
/* Video info */
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './system-config.entity';
|
||||
export * from './tag.entity';
|
||||
export * from './user-album.entity';
|
||||
export * from './user.entity';
|
||||
export * from './user-token.entity';
|
||||
export * from './shared-link.entity';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('user_token')
|
||||
export class UserTokenEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ select: false })
|
||||
token!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity)
|
||||
user!: UserEntity;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: string;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateUserTokenEntity1674342044239 implements MigrationInterface {
|
||||
name = 'CreateUserTokenEntity1674342044239'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "user_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "userId" uuid, CONSTRAINT "PK_48cb6b5c20faa63157b3c1baf7f" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" 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 "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||
await queryRunner.query(`DROP TABLE "user_token"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AlterExifExposureTimeToString1674757936889 implements MigrationInterface {
|
||||
name = 'AlterExifExposureTimeToString1674757936889'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`);
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`);
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" double precision`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class TruncateAPIKeys1674774248319 implements MigrationInterface {
|
||||
name = 'TruncateAPIKeys1674774248319'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`TRUNCATE TABLE "api_keys"`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
//noop
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,14 +21,14 @@ export class APIKeyRepository implements IKeyRepository {
|
||||
await this.repository.delete({ userId, id });
|
||||
}
|
||||
|
||||
getKey(id: number): Promise<APIKeyEntity | null> {
|
||||
getKey(hashedToken: string): Promise<APIKeyEntity | null> {
|
||||
return this.repository.findOne({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
},
|
||||
where: { id },
|
||||
where: { key: hashedToken },
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './api-key.repository';
|
||||
export * from './shared-link.repository';
|
||||
export * from './user.repository';
|
||||
export * from './user-token.repository';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserTokenEntity } from '@app/infra/db/entities/user-token.entity';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class UserTokenRepository implements IUserTokenRepository {
|
||||
constructor(
|
||||
@InjectRepository(UserTokenEntity)
|
||||
private userTokenRepository: Repository<UserTokenEntity>,
|
||||
) {}
|
||||
|
||||
async get(userToken: string): Promise<UserTokenEntity | null> {
|
||||
return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
|
||||
}
|
||||
|
||||
async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.userTokenRepository.save(userToken);
|
||||
}
|
||||
|
||||
async delete(userToken: string): Promise<void> {
|
||||
await this.userTokenRepository.delete(userToken);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,17 @@ import {
|
||||
IUserRepository,
|
||||
QueueName,
|
||||
} from '@app/domain';
|
||||
import { databaseConfig, UserEntity } from './db';
|
||||
import { databaseConfig, UserEntity, UserTokenEntity } from './db';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db';
|
||||
import { APIKeyRepository, SharedLinkRepository } from './db/repository';
|
||||
import { jwtConfig } from '@app/domain';
|
||||
import { CryptoRepository } from './auth/crypto.repository';
|
||||
import { SystemConfigRepository } from './db/repository/system-config.repository';
|
||||
import { JobRepository } from './job';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository';
|
||||
|
||||
const providers: Provider[] = [
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
@@ -26,14 +26,14 @@ const providers: Provider[] = [
|
||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
|
||||
];
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register(jwtConfig),
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]),
|
||||
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity, UserTokenEntity]),
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async () => ({
|
||||
prefix: 'immich_bull',
|
||||
@@ -64,6 +64,6 @@ const providers: Provider[] = [
|
||||
),
|
||||
],
|
||||
providers: [...providers],
|
||||
exports: [...providers, BullModule, JwtModule],
|
||||
exports: [...providers, BullModule],
|
||||
})
|
||||
export class InfraModule {}
|
||||
|
||||
Generated
+1
-209
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -13,7 +13,6 @@
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^10.0.1",
|
||||
"@nestjs/mapped-types": "1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
@@ -50,7 +49,6 @@
|
||||
"passport": "^0.6.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-http-header-strategy": "^1.1.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pg": "^8.8.0",
|
||||
"redis": "^4.5.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@@ -83,7 +81,6 @@
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/mv": "^2.1.2",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/sharp": "^0.30.2",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
@@ -1521,18 +1518,6 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/jwt": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz",
|
||||
"integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "8.5.9",
|
||||
"jsonwebtoken": "9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/mapped-types": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz",
|
||||
@@ -2714,14 +2699,6 @@
|
||||
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz",
|
||||
"integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.178",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz",
|
||||
@@ -2770,36 +2747,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz",
|
||||
"integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz",
|
||||
"integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz",
|
||||
"integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prettier": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz",
|
||||
@@ -3973,11 +3920,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk="
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -5019,14 +4961,6 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -7895,21 +7829,6 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
|
||||
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsprim": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
||||
@@ -7924,25 +7843,6 @@
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kdt": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz",
|
||||
@@ -9005,15 +8905,6 @@
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
@@ -12769,15 +12660,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/jwt": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz",
|
||||
"integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==",
|
||||
"requires": {
|
||||
"@types/jsonwebtoken": "8.5.9",
|
||||
"jsonwebtoken": "9.0.0"
|
||||
}
|
||||
},
|
||||
"@nestjs/mapped-types": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz",
|
||||
@@ -13715,14 +13597,6 @@
|
||||
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
|
||||
"dev": true
|
||||
},
|
||||
"@types/jsonwebtoken": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz",
|
||||
"integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.178",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz",
|
||||
@@ -13771,36 +13645,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
|
||||
},
|
||||
"@types/passport": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz",
|
||||
"integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"@types/passport-jwt": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz",
|
||||
"integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*",
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"@types/passport-strategy": {
|
||||
"version": "0.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz",
|
||||
"integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"@types/prettier": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz",
|
||||
@@ -14727,11 +14571,6 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="
|
||||
},
|
||||
"buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk="
|
||||
},
|
||||
"buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -15545,14 +15384,6 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"requires": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -17690,17 +17521,6 @@
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"jsonwebtoken": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
|
||||
"integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==",
|
||||
"requires": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.3.8"
|
||||
}
|
||||
},
|
||||
"jsprim": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
||||
@@ -17712,25 +17532,6 @@
|
||||
"verror": "1.10.0"
|
||||
}
|
||||
},
|
||||
"jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"requires": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"requires": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"kdt": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz",
|
||||
@@ -18555,15 +18356,6 @@
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"requires": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -29,6 +29,10 @@
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand",
|
||||
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
|
||||
"typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./libs/infra/src/db/config/database.config.ts",
|
||||
"typeorm:migrations:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d ./libs/infra/src/db/config/database.config.ts",
|
||||
"typeorm:migrations:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d ./libs/infra/src/db/config/database.config.ts",
|
||||
"typeorm:schema:drop": "node --require ts-node/register ./node_modules/typeorm/cli.js schema:drop -d ./libs/infra/src/db/config/database.config.ts",
|
||||
"api:typescript": "bash ./bin/generate-open-api.sh web",
|
||||
"api:dart": "bash ./bin/generate-open-api.sh mobile",
|
||||
"api:generate": "bash ./bin/generate-open-api.sh"
|
||||
@@ -38,7 +42,6 @@
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^10.0.1",
|
||||
"@nestjs/mapped-types": "1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
@@ -75,7 +78,6 @@
|
||||
"passport": "^0.6.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-http-header-strategy": "^1.1.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pg": "^8.8.0",
|
||||
"redis": "^4.5.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@@ -105,7 +107,6 @@
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/mv": "^2.1.2",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/sharp": "^0.30.2",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
|
||||
Reference in New Issue
Block a user