Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf
This commit is contained in:
@@ -15,7 +15,7 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY)
|
||||
super(options);
|
||||
}
|
||||
|
||||
validate(token: string): Promise<AuthUserDto> {
|
||||
validate(token: string): Promise<AuthUserDto | null> {
|
||||
return this.apiKeyService.validate(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
validate(key: string): Promise<AuthUserDto | null> {
|
||||
return this.shareService.validate(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService, AuthUserDto } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { AuthService, AuthUserDto, UserService } from '@app/domain';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import { Request } from 'express';
|
||||
import { Strategy } from 'passport-custom';
|
||||
|
||||
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
|
||||
constructor(private userService: UserService, private authService: AuthService) {
|
||||
constructor(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;
|
||||
validate(request: Request): Promise<AuthUserDto | null> {
|
||||
return this.authService.validate(request.headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ async function bootstrap() {
|
||||
logger: getLogLevels(),
|
||||
});
|
||||
|
||||
const listeningPort = Number(process.env.MACHINE_LEARNING_PORT) || 3002;
|
||||
const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002;
|
||||
|
||||
const redisIoAdapter = new RedisIoAdapter(app);
|
||||
await redisIoAdapter.connectToRedis();
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import { AssetType } from '@app/infra';
|
||||
import {
|
||||
IAssetUploadedJob,
|
||||
IMetadataExtractionJob,
|
||||
IThumbnailGenerationJob,
|
||||
IVideoTranscodeJob,
|
||||
QueueName,
|
||||
JobName,
|
||||
} from '@app/domain';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { IAssetUploadedJob, JobName, JobService, QueueName } from '@app/domain';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
|
||||
@Processor(QueueName.ASSET_UPLOADED)
|
||||
export class AssetUploadedProcessor {
|
||||
constructor(
|
||||
@InjectQueue(QueueName.THUMBNAIL_GENERATION)
|
||||
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
||||
constructor(private jobService: JobService) {}
|
||||
|
||||
@InjectQueue(QueueName.METADATA_EXTRACTION)
|
||||
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||
|
||||
@InjectQueue(QueueName.VIDEO_CONVERSION)
|
||||
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Post processing uploaded asset to perform the following function if missing
|
||||
* 1. Generate JPEG Thumbnail
|
||||
* 2. Generate Webp Thumbnail
|
||||
* 3. EXIF extractor
|
||||
* 4. Reverse Geocoding
|
||||
*
|
||||
* @param job asset-uploaded
|
||||
*/
|
||||
@Process(JobName.ASSET_UPLOADED)
|
||||
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
await this.thumbnailGeneratorQueue.add(JobName.GENERATE_JPEG_THUMBNAIL, { asset });
|
||||
|
||||
// Video Conversion
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset });
|
||||
await this.metadataExtractionQueue.add(JobName.EXTRACT_VIDEO_METADATA, { asset, fileName });
|
||||
} else {
|
||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||
await this.metadataExtractionQueue.add(JobName.EXIF_EXTRACTION, { asset, fileName });
|
||||
}
|
||||
await this.jobService.handleUploadedAsset(job);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2707,7 +2707,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APIKeyEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
@@ -124,7 +124,7 @@ describe(APIKeyService.name, () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate(token)).resolves.toBeNull();
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyCreateDto } from './dto/api-key-create.dto';
|
||||
@@ -56,7 +56,7 @@ export class APIKeyService {
|
||||
return keys.map(mapKey);
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto> {
|
||||
async validate(token: string): Promise<AuthUserDto | null> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
@@ -71,6 +71,6 @@ export class APIKeyService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API Key');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ export class AuthCore {
|
||||
let accessTokenCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
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;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
} 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;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
}
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('AuthService', () => {
|
||||
describe('validate - api request', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate({ email: 'a', userId: 'test' })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
|
||||
@@ -115,10 +115,10 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto> {
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto | null> {
|
||||
const tokenValue = this.extractTokenFromHeader(headers);
|
||||
if (!tokenValue) {
|
||||
throw new UnauthorizedException('No access token provided in request');
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
@@ -133,7 +133,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid access token provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { ShareService } from './share';
|
||||
import { AuthService } from './auth';
|
||||
import { JobService } from './job';
|
||||
import { OAuthService } from './oauth';
|
||||
import { ShareService } from './share';
|
||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
APIKeyService,
|
||||
AuthService,
|
||||
JobService,
|
||||
OAuthService,
|
||||
SystemConfigService,
|
||||
UserService,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './interfaces';
|
||||
export * from './job.constants';
|
||||
export * from './job.repository';
|
||||
export * from './job.service';
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface JobCounts {
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
export interface Job<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
||||
| { name: JobName.VIDEO_CONVERSION; data: IVideoConversionProcessor }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
||||
import { newJobRepositoryMock } from '../../test';
|
||||
import { IAssetUploadedJob } from './interfaces';
|
||||
import { JobName } from './job.constants';
|
||||
import { IJobRepository, Job } from './job.repository';
|
||||
import { JobService } from './job.service';
|
||||
|
||||
const jobStub = {
|
||||
upload: {
|
||||
video: Object.freeze<Job<IAssetUploadedJob>>({
|
||||
data: { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' },
|
||||
}),
|
||||
image: Object.freeze<Job<IAssetUploadedJob>>({
|
||||
data: { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new JobService(jobMock);
|
||||
});
|
||||
|
||||
describe('handleUploadedAsset', () => {
|
||||
it('should process a video', async () => {
|
||||
await expect(sut.handleUploadedAsset(jobStub.upload.video)).resolves.toBeUndefined();
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledTimes(3);
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.VIDEO } } }],
|
||||
[{ name: JobName.VIDEO_CONVERSION, data: { asset: { type: AssetType.VIDEO } } }],
|
||||
[{ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset: { type: AssetType.VIDEO }, fileName: 'video.mp4' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process an image', async () => {
|
||||
await sut.handleUploadedAsset(jobStub.upload.image);
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledTimes(2);
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.IMAGE } } }],
|
||||
[{ name: JobName.EXIF_EXTRACTION, data: { asset: { type: AssetType.IMAGE }, fileName: 'image.jpg' } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IAssetUploadedJob } from './interfaces';
|
||||
import { JobUploadCore } from './job.upload.core';
|
||||
import { IJobRepository, Job } from './job.repository';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
private uploadCore: JobUploadCore;
|
||||
|
||||
constructor(@Inject(IJobRepository) repository: IJobRepository) {
|
||||
this.uploadCore = new JobUploadCore(repository);
|
||||
}
|
||||
|
||||
async handleUploadedAsset(job: Job<IAssetUploadedJob>) {
|
||||
await this.uploadCore.handleAsset(job);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AssetType } from '@app/infra/db/entities';
|
||||
import { IAssetUploadedJob } from './interfaces';
|
||||
import { JobName } from './job.constants';
|
||||
import { IJobRepository, Job } from './job.repository';
|
||||
|
||||
export class JobUploadCore {
|
||||
constructor(private repository: IJobRepository) {}
|
||||
|
||||
/**
|
||||
* Post processing uploaded asset to perform the following function
|
||||
* 1. Generate JPEG Thumbnail
|
||||
* 2. Generate Webp Thumbnail
|
||||
* 3. EXIF extractor
|
||||
* 4. Reverse Geocoding
|
||||
*
|
||||
* @param job asset-uploaded
|
||||
*/
|
||||
async handleAsset(job: Job<IAssetUploadedJob>) {
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
await this.repository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
|
||||
|
||||
// Video Conversion
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.repository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
await this.repository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName } });
|
||||
} else {
|
||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||
await this.repository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
authStub,
|
||||
userEntityStub,
|
||||
@@ -34,18 +34,18 @@ describe(ShareService.name, () => {
|
||||
describe('validate', () => {
|
||||
it('should not accept a non-existant key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { EditSharedLinkDto } from './dto';
|
||||
@@ -28,7 +21,7 @@ export class ShareService {
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
async validate(key: string): Promise<AuthUserDto | null> {
|
||||
const link = await this.shareCore.getByKey(key);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
@@ -47,7 +40,7 @@ export class ShareService {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
return null;
|
||||
}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
|
||||
@@ -233,8 +233,8 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
user1password: {
|
||||
@@ -249,8 +249,8 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
user1insecure: {
|
||||
@@ -265,8 +265,8 @@ export const loginResponseStub = {
|
||||
shouldChangePassword: false,
|
||||
},
|
||||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;',
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
Reference in New Issue
Block a user