WIP refactor container and queuing system (#206)

* refactor microservices to machine-learning

* Update tGithub issue template with correct task syntax

* Added microservices container

* Communicate between service based on queue system

* added dependency

* Fixed problem with having to import BullQueue into the individual service

* Added todo

* refactor server into monorepo with microservices

* refactor database and entity to library

* added simple migration

* Move migrations and database config to library

* Migration works in library

* Cosmetic change in logging message

* added user dto

* Fixed issue with testing not able to find the shared library

* Clean up library mapping path

* Added webp generator to microservices

* Update Github Action build latest

* Fixed issue NPM cannot install due to conflict witl Bull Queue

* format project with prettier

* Modified docker-compose file

* Add GH Action for Staging build:

* Fixed GH action job name

* Modified GH Action to only build & push latest when pushing to main

* Added Test 2e2 Github Action

* Added Test 2e2 Github Action

* Implemented microservice to extract exif

* Added cronjob to scan and generate webp thumbnail  at midnight

* Refactor to ireduce hit time to database when running microservices

* Added error handling to asset services that handle read file from disk

* Added video transcoding queue to process one video at a time

* Fixed loading spinner on web while loading covering the info panel

* Add mechanism to show new release announcement to web and mobile app (#209)

* Added changelog page

* Fixed issues based on PR comments

* Fixed issue with video transcoding run on the server

* Change entry point content for backward combatibility when starting up server

* Added announcement box

* Added error handling to failed silently when the app version checking is not able to make the request to GITHUB

* Added new version announcement overlay

* Update message

* Added messages

* Added logic to check and show announcement

* Add method to handle saving new version

* Added button to dimiss the acknowledge message

* Up version for deployment to the app store
This commit is contained in:
Alex
2022-06-11 16:12:06 -05:00
committed by GitHub
parent 397f8c70b4
commit a8220172f8
192 changed files with 1823 additions and 2117 deletions

View File

@@ -1,158 +0,0 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFiles,
Body,
UseGuards,
Get,
Param,
ValidationPipe,
StreamableFile,
Query,
Response,
Headers,
Delete,
Logger,
Patch,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { AssetEntity } from './entities/asset.entity';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { Response as Res } from 'express';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway';
@UseGuards(JwtAuthGuard)
@Controller('asset')
export class AssetController {
constructor(
private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
) { }
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor(
[
{ name: 'assetData', maxCount: 1 },
{ name: 'thumbnailData', maxCount: 1 },
],
assetUploadOption,
),
)
async uploadFile(
@GetAuthUser() authUser,
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto,
) {
for (const file of uploadFiles.assetData) {
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
if (uploadFiles.thumbnailData != null && savedAsset) {
await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path);
await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset);
await this.backgroundTaskService.detectObject(uploadFiles.thumbnailData[0].path, savedAsset);
}
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
} catch (e) {
Logger.error(`Error receiving upload file ${e}`);
}
}
return 'ok';
}
@Get('/download')
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(ValidationPipe) query: ServeFileDto,
) {
return this.assetService.downloadFile(query, res);
}
@Get('/file')
async serveFile(
@Headers() headers,
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(ValidationPipe) query: ServeFileDto,
): Promise<StreamableFile> {
return this.assetService.serveFile(authUser, query, res, headers);
}
@Get('/thumbnail/:assetId')
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> {
return await this.assetService.getAssetThumbnail(assetId);
}
@Get('/allObjects')
async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getCuratedObject(authUser);
}
@Get('/allLocation')
async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getCuratedLocation(authUser);
}
@Get('/searchTerm')
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getAssetSearchTerm(authUser);
}
@Post('/search')
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
return this.assetService.searchAsset(authUser, searchAssetDto);
}
@Get('/')
async getAllAssets(@GetAuthUser() authUser: AuthUserDto) {
return await this.assetService.getAllAssets(authUser);
}
@Get('/:deviceId')
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
}
@Get('/assetById/:assetId')
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
return await this.assetService.getAssetById(authUser, assetId);
}
@Delete('/')
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
const deleteAssetList: AssetEntity[] = [];
for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(authUser, id);
deleteAssetList.push(assets);
}
const result = await this.assetService.deleteAssetById(authUser, assetIds);
result.forEach((res) => {
deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
});
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
return result;
}
}

View File

@@ -1,41 +0,0 @@
import { Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from './entities/asset.entity';
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module';
@Module({
imports: [
CommunicationModule,
BullModule.registerQueue({
name: 'optimize',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]),
ImageOptimizeModule,
BackgroundTaskModule,
],
controllers: [AssetController],
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
exports: [],
})
export class AssetModule { }

View File

@@ -1,365 +0,0 @@
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity';
import _ from 'lodash';
import { createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import ffmpeg from 'fluent-ffmpeg';
const fileInfo = promisify(stat);
@Injectable()
export class AssetService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) { }
public async updateThumbnailInfo(assetId: string, path: string) {
return await this.assetRepository.update(assetId, {
resizePath: path,
});
}
public async createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) {
const asset = new AssetEntity();
asset.deviceAssetId = assetInfo.deviceAssetId;
asset.userId = authUser.id;
asset.deviceId = assetInfo.deviceId;
asset.type = assetInfo.assetType || AssetType.OTHER;
asset.originalPath = path;
asset.createdAt = assetInfo.createdAt;
asset.modifiedAt = assetInfo.modifiedAt;
asset.isFavorite = assetInfo.isFavorite;
asset.mimeType = mimeType;
asset.duration = assetInfo.duration;
try {
return await this.assetRepository.save(asset);
} catch (e) {
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
}
}
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
const rows = await this.assetRepository.find({
where: {
userId: authUser.id,
deviceId: deviceId,
},
select: ['deviceAssetId'],
});
const res = [];
rows.forEach((v) => res.push(v.deviceAssetId));
return res;
}
public async getAllAssets(authUser: AuthUserDto) {
try {
return await this.assetRepository.find({
where: {
userId: authUser.id
},
relations: ['exifInfo'],
order: {
createdAt: 'DESC'
}
})
} catch (e) {
Logger.error(e, 'getAllAssets');
}
}
public async findOne(deviceId: string, assetId: string): Promise<AssetEntity> {
const rows = await this.assetRepository.query(
'SELECT * FROM assets a WHERE a."deviceAssetId" = $1 AND a."deviceId" = $2',
[assetId, deviceId],
);
if (rows.lengh == 0) {
throw new BadRequestException('Not Found');
}
return rows[0] as AssetEntity;
}
public async getAssetById(authUser: AuthUserDto, assetId: string) {
return await this.assetRepository.findOne({
where: {
id: assetId,
},
relations: ['exifInfo'],
});
}
public async downloadFile(query: ServeFileDto, res: Res) {
let file = null;
const asset = await this.findOne(query.did, query.aid);
if (query.isThumb === 'false' || !query.isThumb) {
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
file = createReadStream(asset.originalPath);
} else {
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
file = createReadStream(asset.resizePath);
}
return new StreamableFile(file);
}
public async getAssetThumbnail(assetId: string) {
const asset = await this.assetRepository.findOne({ id: assetId });
if (asset.webpPath != '') {
return new StreamableFile(createReadStream(asset.webpPath));
} else {
return new StreamableFile(createReadStream(asset.resizePath));
}
}
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
let file = null;
const asset = await this.findOne(query.did, query.aid);
if (!asset) {
throw new BadRequestException('Asset does not exist');
}
// Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
res.set({
'Content-Type': 'image/jpeg',
});
return new StreamableFile(createReadStream(asset.resizePath));
}
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) {
res.set({
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.originalPath);
} else {
if (asset.webpPath != '') {
res.set({
'Content-Type': 'image/webp',
});
file = createReadStream(asset.webpPath);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
file = createReadStream(asset.resizePath);
}
}
file.on('error', (error) => {
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
} else if (asset.type == AssetType.VIDEO) {
// Handle Video
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
if (query.isWeb && asset.mimeType == 'video/quicktime') {
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
}
const { size } = await fileInfo(videoPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({
'Content-Range': `bytes */${size}`,
});
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType,
});
const videoStream = createReadStream(videoPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': mimeType,
});
return new StreamableFile(createReadStream(videoPath));
}
}
}
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
const result = [];
const target = assetIds.ids;
for (const assetId of target) {
const res = await this.assetRepository.delete({
id: assetId,
userId: authUser.id,
});
if (res.affected) {
result.push({
id: assetId,
status: 'success',
});
} else {
result.push({
id: assetId,
status: 'failed',
});
}
}
return result;
}
async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
const possibleSearchTerm = new Set<string>();
const rows = await this.assetRepository.query(
`
select distinct si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
from assets a
left join exif e on a.id = e."assetId"
left join smart_info si on a.id = si."assetId"
where a."userId" = $1;
`,
[authUser.id],
);
rows.forEach((row) => {
// tags
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
// objects
row['objects']?.map((object) => possibleSearchTerm.add(object?.toLowerCase()));
// asset's tyoe
possibleSearchTerm.add(row['type']?.toLowerCase());
// image orientation
possibleSearchTerm.add(row['orientation']?.toLowerCase());
// Lens model
possibleSearchTerm.add(row['lensModel']?.toLowerCase());
// Make and model
possibleSearchTerm.add(row['make']?.toLowerCase());
possibleSearchTerm.add(row['model']?.toLowerCase());
// Location
possibleSearchTerm.add(row['city']?.toLowerCase());
possibleSearchTerm.add(row['state']?.toLowerCase());
possibleSearchTerm.add(row['country']?.toLowerCase());
});
return Array.from(possibleSearchTerm).filter((x) => x != null);
}
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
const query = `
SELECT a.*
FROM assets a
LEFT JOIN smart_info si ON a.id = si."assetId"
LEFT JOIN exif e ON a.id = e."assetId"
WHERE a."userId" = $1
AND
(
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
);
`;
return await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
}
async getCuratedLocation(authUser: AuthUserDto) {
return await this.assetRepository.query(
`
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
from assets a
left join exif e on a.id = e."assetId"
where a."userId" = $1
and e.city is not null
and a.type = 'IMAGE';
`,
[authUser.id],
);
}
async getCuratedObject(authUser: AuthUserDto) {
return await this.assetRepository.query(
`
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
from assets a
left join smart_info si on a.id = si."assetId"
where a."userId" = $1
and si.objects is not null
`,
[authUser.id],
);
}
}

View File

@@ -1,28 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { AssetType } from '../entities/asset.entity';
export class CreateAssetDto {
@IsNotEmpty()
deviceAssetId: string;
@IsNotEmpty()
deviceId: string;
@IsNotEmpty()
assetType: AssetType;
@IsNotEmpty()
createdAt: string;
@IsNotEmpty()
modifiedAt: string;
@IsNotEmpty()
isFavorite: boolean;
@IsNotEmpty()
fileExtension: string;
@IsOptional()
duration: string;
}

View File

@@ -1,48 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateExifDto {
@IsNotEmpty()
assetId: string;
@IsOptional()
make: string;
@IsOptional()
model: string;
@IsOptional()
imageName: string;
@IsOptional()
exifImageWidth: number;
@IsOptional()
exifImageHeight: number;
@IsOptional()
fileSizeInByte: number;
@IsOptional()
orientation: string;
@IsOptional()
dateTimeOriginal: Date;
@IsOptional()
modifiedDate: Date;
@IsOptional()
lensModel: string;
@IsOptional()
fNumber: number;
@IsOptional()
focalLenght: number;
@IsOptional()
iso: number;
@IsOptional()
exposureTime: number;
}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class DeleteAssetDto {
@IsNotEmpty()
ids: string[];
}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class GetAllAssetQueryDto {
@IsOptional()
nextPageKey: string;
}

View File

@@ -1,7 +0,0 @@
import { AssetEntity } from '../entities/asset.entity';
export class GetAllAssetReponseDto {
data: Array<{ date: string; assets: Array<AssetEntity> }>;
count: number;
nextPageKey: string;
}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class GetAssetDto {
@IsNotEmpty()
deviceId: string;
}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class GetNewAssetQueryDto {
@IsNotEmpty()
latestDate: string;
}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class SearchAssetDto {
@IsNotEmpty()
searchTerm: string;
}

View File

@@ -1,20 +0,0 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto {
//assetId
@IsNotEmpty()
aid: string;
//deviceId
@IsNotEmpty()
did: string;
@IsOptional()
@IsBooleanString()
isThumb: string;
@IsOptional()
@IsBooleanString()
isWeb: string;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAssetDto } from './create-asset.dto';
export class UpdateAssetDto extends PartialType(CreateAssetDto) {}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExifDto } from './create-exif.dto';
export class UpdateExifDto extends PartialType(CreateExifDto) {}

View File

@@ -1,62 +0,0 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
import { SmartInfoEntity } from './smart-info.entity';
@Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId'])
export class AssetEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
deviceAssetId: string;
@Column()
userId: string;
@Column()
deviceId: string;
@Column()
type: AssetType;
@Column()
originalPath: string;
@Column({ nullable: true })
resizePath: string;
@Column({ nullable: true })
webpPath: string;
@Column({ nullable: true })
encodedVideoPath: string;
@Column()
createdAt: string;
@Column()
modifiedAt: string;
@Column({ type: 'boolean', default: false })
isFavorite: boolean;
@Column({ nullable: true })
mimeType: string;
@Column({ nullable: true })
duration: string;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo: ExifEntity;
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
smartInfo: SmartInfoEntity;
}
export enum AssetType {
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
AUDIO = 'AUDIO',
OTHER = 'OTHER',
}

View File

@@ -1,76 +0,0 @@
import { Index, JoinColumn, OneToOne } from 'typeorm';
import { Column } from 'typeorm/decorator/columns/Column';
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
import { Entity } from 'typeorm/decorator/entity/Entity';
import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ nullable: true })
make: string;
@Column({ nullable: true })
model: string;
@Column({ nullable: true })
imageName: string;
@Column({ nullable: true })
exifImageWidth: number;
@Column({ nullable: true })
exifImageHeight: number;
@Column({ nullable: true })
fileSizeInByte: number;
@Column({ nullable: true })
orientation: string;
@Column({ type: 'timestamptz', nullable: true })
dateTimeOriginal: Date;
@Column({ type: 'timestamptz', nullable: true })
modifyDate: Date;
@Column({ nullable: true })
lensModel: string;
@Column({ type: 'float8', nullable: true })
fNumber: number;
@Column({ type: 'float8', nullable: true })
focalLength: number;
@Column({ nullable: true })
iso: number;
@Column({ type: 'float', nullable: true })
exposureTime: number;
@Column({ type: 'float', nullable: true })
latitude: number;
@Column({ type: 'float', nullable: true })
longitude: number;
@Column({ nullable: true })
city: string;
@Column({ nullable: true })
state: string;
@Column({ nullable: true })
country: string;
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: ExifEntity;
}

View File

@@ -1,22 +0,0 @@
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { AssetEntity } from './asset.entity';
@Entity('smart_info')
export class SmartInfoEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ type: 'text', array: true, nullable: true })
tags: string[];
@Column({ type: 'text', array: true, nullable: true })
objects: string[];
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: SmartInfoEntity;
}

View File

@@ -1,29 +0,0 @@
import { Body, Controller, Post, UseGuards, ValidationPipe } from '@nestjs/common';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthService } from './auth.service';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) { }
@Post('/login')
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {
return await this.authService.login(loginCredential);
}
@Post('/admin-sign-up')
async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.adminSignUp(signUpCrendential);
}
@UseGuards(JwtAuthGuard)
@Post('/validateToken')
async validateToken(@GetAuthUser() authUser: AuthUserDto) {
return {
authStatus: true,
};
}
}

View File

@@ -1,16 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [AuthController],
providers: [AuthService, ImmichJwtService],
})
export class AuthModule {}

View File

@@ -1,101 +0,0 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtPayloadDto } from './dto/jwt-payload.dto';
import { SignUpDto } from './dto/sign-up.dto';
import * as bcrypt from 'bcrypt';
import { mapUser, User } from '../user/response-dto/user';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService,
) {}
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
const user = await this.userRepository.findOne(
{ email: loginCredential.email },
{
select: [
'id',
'email',
'password',
'salt',
'firstName',
'lastName',
'isAdmin',
'profileImagePath',
'isFirstLoggedIn',
],
},
);
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
if (user && isAuthenticated) {
return user;
}
return null;
}
public async login(loginCredential: LoginCredentialDto) {
const validatedUser = await this.validateUser(loginCredential);
if (!validatedUser) {
throw new BadRequestException('Incorrect email or password');
}
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
return {
accessToken: await this.immichJwtService.generateToken(payload),
userId: validatedUser.id,
userEmail: validatedUser.email,
firstName: validatedUser.firstName,
lastName: validatedUser.lastName,
isAdmin: validatedUser.isAdmin,
profileImagePath: validatedUser.profileImagePath,
isFirstLogin: validatedUser.isFirstLoggedIn,
};
}
public async adminSignUp(signUpCredential: SignUpDto): Promise<User> {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
throw new BadRequestException('The server already has an admin');
}
const newAdminUser = new UserEntity();
newAdminUser.email = signUpCredential.email;
newAdminUser.salt = await bcrypt.genSalt();
newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
newAdminUser.firstName = signUpCredential.firstName;
newAdminUser.lastName = signUpCredential.lastName;
newAdminUser.isAdmin = true;
try {
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
return mapUser(savedNewAdminUserUser);
} catch (e) {
Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new admin user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
const hash = await bcrypt.hash(inputPassword, salt);
return hash === hasedPassword;
}
}

View File

@@ -1,9 +0,0 @@
export class JwtPayloadDto {
constructor(userId: string, email: string) {
this.userId = userId;
this.email = email;
}
userId: string;
email: string;
}

View File

@@ -1,9 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class LoginCredentialDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
}

View File

@@ -1,15 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class SignUpDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
}

View File

@@ -1,47 +0,0 @@
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { CommunicationService } from './communication.service';
import { Socket, Server } from 'socket.io';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
@WebSocketGateway()
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
private immichJwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
@WebSocketServer() server: Server;
handleDisconnect(client: Socket) {
client.leave(client.nsp.name);
Logger.log(`Client ${client.id} disconnected`);
}
async handleConnection(client: Socket, ...args: any[]) {
Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection');
const accessToken = client.handshake.headers.authorization.split(' ')[1];
const res = await this.immichJwtService.validateToken(accessToken);
if (!res.status) {
client.emit('error', 'unauthorized');
client.disconnect();
return;
}
const user = await this.userRepository.findOne({ where: { id: res.userId } });
if (!user) {
client.emit('error', 'unauthorized');
client.disconnect();
return;
}
client.join(user.id);
}
}

View File

@@ -1,16 +0,0 @@
import { Module } from '@nestjs/common';
import { CommunicationService } from './communication.service';
import { CommunicationGateway } from './communication.gateway';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
providers: [CommunicationGateway, CommunicationService, ImmichJwtService],
exports: [CommunicationGateway],
})
export class CommunicationModule {}

View File

@@ -1,4 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CommunicationService {}

View File

@@ -1,22 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe } from '@nestjs/common';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { DeviceInfoService } from './device-info.service';
import { CreateDeviceInfoDto } from './dto/create-device-info.dto';
import { UpdateDeviceInfoDto } from './dto/update-device-info.dto';
@UseGuards(JwtAuthGuard)
@Controller('device-info')
export class DeviceInfoController {
constructor(private readonly deviceInfoService: DeviceInfoService) {}
@Post()
async create(@Body(ValidationPipe) createDeviceInfoDto: CreateDeviceInfoDto, @GetAuthUser() authUser: AuthUserDto) {
return await this.deviceInfoService.create(createDeviceInfoDto, authUser);
}
@Patch()
async update(@Body(ValidationPipe) updateDeviceInfoDto: UpdateDeviceInfoDto, @GetAuthUser() authUser: AuthUserDto) {
return this.deviceInfoService.update(authUser.id, updateDeviceInfoDto);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { DeviceInfoService } from './device-info.service';
import { DeviceInfoController } from './device-info.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeviceInfoEntity } from './entities/device-info.entity';
@Module({
imports: [TypeOrmModule.forFeature([DeviceInfoEntity])],
controllers: [DeviceInfoController],
providers: [DeviceInfoService],
})
export class DeviceInfoModule {}

View File

@@ -1,63 +0,0 @@
import { BadRequestException, HttpCode, Injectable, Logger, Res } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateDeviceInfoDto } from './dto/create-device-info.dto';
import { UpdateDeviceInfoDto } from './dto/update-device-info.dto';
import { DeviceInfoEntity } from './entities/device-info.entity';
@Injectable()
export class DeviceInfoService {
constructor(
@InjectRepository(DeviceInfoEntity)
private deviceRepository: Repository<DeviceInfoEntity>,
) {}
async create(createDeviceInfoDto: CreateDeviceInfoDto, authUser: AuthUserDto) {
const res = await this.deviceRepository.findOne({
deviceId: createDeviceInfoDto.deviceId,
userId: authUser.id,
});
if (res) {
Logger.log('Device Info Exist', 'createDeviceInfo');
return res;
}
const deviceInfo = new DeviceInfoEntity();
deviceInfo.deviceId = createDeviceInfoDto.deviceId;
deviceInfo.deviceType = createDeviceInfoDto.deviceType;
deviceInfo.userId = authUser.id;
try {
return await this.deviceRepository.save(deviceInfo);
} catch (e) {
Logger.error('Error creating new device info', 'createDeviceInfo');
}
}
async update(userId: string, updateDeviceInfoDto: UpdateDeviceInfoDto) {
const deviceInfo = await this.deviceRepository.findOne({
where: { deviceId: updateDeviceInfoDto.deviceId, userId: userId },
});
if (!deviceInfo) {
throw new BadRequestException('Device Not Found');
}
const res = await this.deviceRepository.update(
{
id: deviceInfo.id,
},
updateDeviceInfoDto,
);
if (res.affected == 1) {
return await this.deviceRepository.findOne({
where: { deviceId: updateDeviceInfoDto.deviceId, userId: userId },
});
} else {
throw new BadRequestException('Bad Request');
}
}
}

View File

@@ -1,13 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { DeviceType } from '../entities/device-info.entity';
export class CreateDeviceInfoDto {
@IsNotEmpty()
deviceId: string;
@IsNotEmpty()
deviceType: DeviceType;
@IsOptional()
isAutoBackup: boolean;
}

View File

@@ -1,6 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsOptional } from 'class-validator';
import { DeviceType } from '../entities/device-info.entity';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto extends PartialType(CreateDeviceInfoDto) {}

View File

@@ -1,32 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity('device_info')
@Unique(['userId', 'deviceId'])
export class DeviceInfoEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
userId: string;
@Column()
deviceId: string;
@Column()
deviceType: DeviceType;
@Column({ nullable: true })
notificationToken: string;
@CreateDateColumn()
createdAt: string;
@Column({ type: 'bool', default: false })
isAutoBackup: boolean;
}
export enum DeviceType {
IOS = 'IOS',
ANDROID = 'ANDROID',
WEB = 'WEB',
}

View File

@@ -1,9 +0,0 @@
export class ServerInfoDto {
diskSize: String;
diskUse: String;
diskAvailable: String;
diskSizeRaw: number;
diskUseRaw: number;
diskAvailableRaw: number;
diskUsagePercentage: number;
}

View File

@@ -1,36 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant';
@Controller('server-info')
export class ServerInfoController {
constructor(private readonly serverInfoService: ServerInfoService, private readonly configService: ConfigService) {}
@Get()
async getServerInfo() {
return await this.serverInfoService.getServerInfo();
}
@Get('/ping')
async getServerPulse() {
return {
res: 'pong',
};
}
@UseGuards(JwtAuthGuard)
@Get('/mapbox')
async getMapboxInfo() {
return {
isEnable: this.configService.get('ENABLE_MAPBOX'),
mapboxSecret: this.configService.get('MAPBOX_KEY'),
};
}
@Get('/version')
async getServerVersion() {
return serverVersion;
}
}

View File

@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller';
@Module({
controllers: [ServerInfoController],
providers: [ServerInfoService]
})
export class ServerInfoModule {}

View File

@@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ServerInfoDto } from './dto/server-info.dto';
import diskusage from 'diskusage';
@Injectable()
export class ServerInfoService {
async getServerInfo() {
const diskInfo = await diskusage.check('./upload');
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerInfoDto();
serverInfo.diskAvailable = ServerInfoService.getHumanReadableString(diskInfo.available);
serverInfo.diskSize = ServerInfoService.getHumanReadableString(diskInfo.total);
serverInfo.diskUse = ServerInfoService.getHumanReadableString(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
private static getHumanReadableString(sizeInByte: number) {
const pepibyte = 1.126 * Math.pow(10, 15);
const tebibyte = 1.1 * Math.pow(10, 12);
const gibibyte = 1.074 * Math.pow(10, 9);
const mebibyte = 1.049 * Math.pow(10, 6);
const kibibyte = 1024;
// Pebibyte
if (sizeInByte >= pepibyte) {
// Pe
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
// Te
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
// Gi
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
// Mega
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
// Kibi
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
} else {
return `${sizeInByte}B`;
}
}
}

View File

@@ -1,10 +0,0 @@
import { IsNotEmpty } from 'class-validator';
import { AssetEntity } from '../../asset/entities/asset.entity';
export class AddAssetsDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty()
assetIds: string[];
}

View File

@@ -1,9 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class AddUsersDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty()
sharedUserIds: string[];
}

View File

@@ -1,13 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { AssetEntity } from '../../asset/entities/asset.entity';
export class CreateSharedAlbumDto {
@IsNotEmpty()
albumName: string;
@IsNotEmpty()
sharedWithUserIds: string[];
@IsOptional()
assetIds: string[];
}

View File

@@ -1,9 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class RemoveAssetsDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty()
assetIds: string[];
}

View File

@@ -1,12 +0,0 @@
import { IsNotEmpty } from 'class-validator';
export class UpdateShareAlbumDto {
@IsNotEmpty()
albumId: string;
@IsNotEmpty()
albumName: string;
@IsNotEmpty()
ownerId: string;
}

View File

@@ -1,30 +0,0 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AssetEntity } from '../../asset/entities/asset.entity';
import { SharedAlbumEntity } from './shared-album.entity';
@Entity('asset_shared_album')
@Unique('PK_unique_asset_in_album', ['albumId', 'assetId'])
export class AssetSharedAlbumEntity {
@PrimaryGeneratedColumn()
id: string;
@Column()
albumId: string;
@Column()
assetId: string;
@ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedAssets, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'albumId' })
albumInfo: SharedAlbumEntity;
@ManyToOne(() => AssetEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'assetId' })
assetInfo: AssetEntity;
}

View File

@@ -1,27 +0,0 @@
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { AssetSharedAlbumEntity } from './asset-shared-album.entity';
import { UserSharedAlbumEntity } from './user-shared-album.entity';
@Entity('shared_albums')
export class SharedAlbumEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
ownerId: string;
@Column({ default: 'Untitled Album' })
albumName: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: string;
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
albumThumbnailAssetId: string;
@OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo)
sharedUsers: UserSharedAlbumEntity[];
@OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo)
sharedAssets: AssetSharedAlbumEntity[];
}

View File

@@ -1,27 +0,0 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { UserEntity } from '../../user/entities/user.entity';
import { SharedAlbumEntity } from './shared-album.entity';
@Entity('user_shared_album')
@Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId'])
export class UserSharedAlbumEntity {
@PrimaryGeneratedColumn()
id: string;
@Column()
albumId: string;
@Column()
sharedUserId: string;
@ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedUsers, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'albumId' })
albumInfo: SharedAlbumEntity;
@ManyToOne(() => UserEntity)
@JoinColumn({ name: 'sharedUserId' })
userInfo: UserEntity;
}

View File

@@ -1,61 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common';
import { SharingService } from './sharing.service';
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@UseGuards(JwtAuthGuard)
@Controller('shared')
export class SharingController {
constructor(private readonly sharingService: SharingService) {}
@Post('/createAlbum')
async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) {
return await this.sharingService.create(authUser, createSharedAlbumDto);
}
@Post('/addUsers')
async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) {
return await this.sharingService.addUsersToAlbum(addUsersDto);
}
@Post('/addAssets')
async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) {
return await this.sharingService.addAssetsToAlbum(addAssetsDto);
}
@Get('/allSharedAlbums')
async getAllSharedAlbums(@GetAuthUser() authUser) {
return await this.sharingService.getAllSharedAlbums(authUser);
}
@Get('/:albumId')
async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
return await this.sharingService.getAlbumInfo(authUser, albumId);
}
@Delete('/removeAssets')
async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) {
console.log('removeAssets');
return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto);
}
@Delete('/:albumId')
async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
return await this.sharingService.deleteAlbum(authUser, albumId);
}
@Delete('/leaveAlbum/:albumId')
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
return await this.sharingService.leaveAlbum(authUser, albumId);
}
@Patch('/updateInfo')
async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
}
}

View File

@@ -1,24 +0,0 @@
import { Module } from '@nestjs/common';
import { SharingService } from './sharing.service';
import { SharingController } from './sharing.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../asset/entities/asset.entity';
import { UserEntity } from '../user/entities/user.entity';
import { SharedAlbumEntity } from './entities/shared-album.entity';
import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity';
import { UserSharedAlbumEntity } from './entities/user-shared-album.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
AssetEntity,
UserEntity,
SharedAlbumEntity,
AssetSharedAlbumEntity,
UserSharedAlbumEntity,
]),
],
controllers: [SharingController],
providers: [SharingService],
})
export class SharingModule {}

View File

@@ -1,199 +0,0 @@
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { getConnection, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity } from '../asset/entities/asset.entity';
import { UserEntity } from '../user/entities/user.entity';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity';
import { SharedAlbumEntity } from './entities/shared-album.entity';
import { UserSharedAlbumEntity } from './entities/user-shared-album.entity';
import _ from 'lodash';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
@Injectable()
export class SharingService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(SharedAlbumEntity)
private sharedAlbumRepository: Repository<SharedAlbumEntity>,
@InjectRepository(AssetSharedAlbumEntity)
private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>,
@InjectRepository(UserSharedAlbumEntity)
private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>,
) {}
async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) {
return await getConnection().transaction(async (transactionalEntityManager) => {
// Create album entity
const newSharedAlbum = new SharedAlbumEntity();
newSharedAlbum.ownerId = authUser.id;
newSharedAlbum.albumName = createSharedAlbumDto.albumName;
const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum);
// Add shared users
for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) {
const newSharedUser = new UserSharedAlbumEntity();
newSharedUser.albumId = sharedAlbum.id;
newSharedUser.sharedUserId = sharedUserId;
await transactionalEntityManager.save(newSharedUser);
}
// Add shared assets
const newRecords: AssetSharedAlbumEntity[] = [];
for (const assetId of createSharedAlbumDto.assetIds) {
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
newAssetSharedAlbum.assetId = assetId;
newAssetSharedAlbum.albumId = sharedAlbum.id;
newRecords.push(newAssetSharedAlbum);
}
if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) {
sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId;
await transactionalEntityManager.save(sharedAlbum);
}
await transactionalEntityManager.save([...newRecords]);
return sharedAlbum;
});
}
/**
* Get all shared album, including owned and shared one.
* @param authUser AuthUserDto
* @returns All Shared Album And Its Members
*/
async getAllSharedAlbums(authUser: AuthUserDto) {
const ownedAlbums = await this.sharedAlbumRepository.find({
where: { ownerId: authUser.id },
relations: ['sharedUsers', 'sharedUsers.userInfo'],
});
const isSharedWithAlbums = await this.userSharedAlbumRepository.find({
where: {
sharedUserId: authUser.id,
},
relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'],
select: ['albumInfo'],
});
return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort(
(a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(),
);
}
async getAlbumInfo(authUser: AuthUserDto, albumId: string) {
const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } });
const personShared = await this.userSharedAlbumRepository.findOne({
where: { albumId: albumId, sharedUserId: authUser.id },
});
if (!(albumOwner || personShared)) {
throw new UnauthorizedException('Unauthorized Album Access');
}
const albumInfo = await this.sharedAlbumRepository.findOne({
where: { id: albumId },
relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'],
});
if (!albumInfo) {
throw new NotFoundException('Album Not Found');
}
const sortedSharedAsset = albumInfo.sharedAssets.sort(
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
);
albumInfo.sharedAssets = sortedSharedAsset;
return albumInfo;
}
async addUsersToAlbum(addUsersDto: AddUsersDto) {
const newRecords: UserSharedAlbumEntity[] = [];
for (const sharedUserId of addUsersDto.sharedUserIds) {
const newEntity = new UserSharedAlbumEntity();
newEntity.albumId = addUsersDto.albumId;
newEntity.sharedUserId = sharedUserId;
newRecords.push(newEntity);
}
return await this.userSharedAlbumRepository.save([...newRecords]);
}
async deleteAlbum(authUser: AuthUserDto, albumId: string) {
return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id });
}
async leaveAlbum(authUser: AuthUserDto, albumId: string) {
return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id });
}
async removeUsersFromAlbum() {}
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) {
let deleteAssetCount = 0;
const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId });
if (album.ownerId != authUser.id) {
throw new BadRequestException("You don't have permission to remove assets in this album");
}
for (const assetId of removeAssetsDto.assetIds) {
const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId });
if (res.affected == 1) deleteAssetCount++;
}
return deleteAssetCount == removeAssetsDto.assetIds.length;
}
async addAssetsToAlbum(addAssetsDto: AddAssetsDto) {
const newRecords: AssetSharedAlbumEntity[] = [];
for (const assetId of addAssetsDto.assetIds) {
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
newAssetSharedAlbum.assetId = assetId;
newAssetSharedAlbum.albumId = addAssetsDto.albumId;
newRecords.push(newAssetSharedAlbum);
}
// Add album thumbnail if not exist.
const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId });
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
album.albumThumbnailAssetId = newRecords[0].assetId;
await this.sharedAlbumRepository.save(album);
}
return await this.assetSharedAlbumRepository.save([...newRecords]);
}
async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
if (authUser.id != updateShareAlbumDto.ownerId) {
throw new BadRequestException('Unauthorized to change album info');
}
const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
sharedAlbum.albumName = updateShareAlbumDto.albumName;
return await this.sharedAlbumRepository.save(sharedAlbum);
}
}

View File

@@ -1,27 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
@IsOptional()
profileImagePath?: string;
@IsOptional()
isAdmin?: boolean;
@IsOptional()
isFirstLoggedIn?: boolean;
@IsOptional()
id?: string;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -1,34 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isAdmin: boolean;
@Column()
email: string;
@Column({ select: false })
password: string;
@Column({ select: false })
salt: string;
@Column()
profileImagePath: string;
@Column()
isFirstLoggedIn: boolean;
@CreateDateColumn()
createdAt: string;
}

View File

@@ -1,19 +0,0 @@
import { UserEntity } from '../entities/user.entity';
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: string;
}
export function mapUser(entity: UserEntity): User {
return {
id: entity.id,
email: entity.email,
firstName: entity.firstName,
lastName: entity.lastName,
createdAt: entity.createdAt,
};
}

View File

@@ -1,54 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query, UseInterceptors, UploadedFile, Response } from '@nestjs/common';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { UpdateUserDto } from './dto/update-user.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
import { Response as Res } from 'express';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) { }
@UseGuards(JwtAuthGuard)
@Get()
async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) {
return await this.userService.getAllUsers(authUser, isAll);
}
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@Post()
async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return await this.userService.createUser(createUserDto);
}
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean) {
return await this.userService.getUserCount(isAdmin);
}
@UseGuards(JwtAuthGuard)
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) {
return await this.userService.updateUser(updateUserDto)
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@Post('/profile-image')
async createProfileImage(@GetAuthUser() authUser: AuthUserDto, @UploadedFile() fileInfo: Express.Multer.File) {
return await this.userService.createProfileImage(authUser, fileInfo);
}
@Get('/profile-image/:userId')
async getProfileImage(@Param('userId') userId: string,
@Response({ passthrough: true }) res: Res,
) {
return await this.userService.getUserProfileImage(userId, res);
}
}

View File

@@ -1,16 +0,0 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController],
providers: [UserService, ImmichJwtService],
})
export class UserModule { }

View File

@@ -1,152 +0,0 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity';
import * as bcrypt from 'bcrypt';
import { createReadStream } from 'fs';
import { Response as Res } from 'express';
import { mapUser, User } from './response-dto/user';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async getAllUsers(authUser: AuthUserDto, isAll: boolean) {
if (isAll) {
return await this.userRepository.find();
}
return await this.userRepository.find({
where: { id: Not(authUser.id) },
order: {
createdAt: 'DESC',
},
});
}
async getUserCount(isAdmin: boolean) {
let users;
if (isAdmin) {
users = await this.userRepository.find({ where: { isAdmin: true } });
} else {
users = await this.userRepository.find();
}
return {
userCount: users.length,
};
}
async createUser(createUserDto: CreateUserDto): Promise<User> {
const user = await this.userRepository.findOne({ where: { email: createUserDto.email } });
if (user) {
throw new BadRequestException('User exists');
}
const newUser = new UserEntity();
newUser.email = createUserDto.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
newUser.firstName = createUserDto.firstName;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
try {
const savedUser = await this.userRepository.save(newUser);
return mapUser(savedUser);
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
async updateUser(updateUserDto: UpdateUserDto) {
const user = await this.userRepository.findOne(updateUserDto.id);
user.lastName = updateUserDto.lastName || user.lastName;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
// If payload includes password - Create new password for user
if (updateUserDto.password) {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(updateUserDto.password, user.salt);
}
if (updateUserDto.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
throw new BadRequestException('Admin user exists');
}
user.isAdmin = true;
}
try {
const updatedUser = await this.userRepository.save(user);
return {
id: updatedUser.id,
email: updatedUser.email,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
profileImagePath: updatedUser.profileImagePath,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) {
try {
await this.userRepository.update(authUser.id, {
profileImagePath: fileInfo.path,
});
return {
userId: authUser.id,
profileImagePath: fileInfo.path,
};
} catch (e) {
Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new user profile image');
}
}
async getUserProfileImage(userId: string, res: Res) {
try {
const user = await this.userRepository.findOne({ id: userId });
if (!user.profileImagePath) {
// throw new BadRequestException('User does not have a profile image');
res.status(404).send('User does not have a profile image');
return;
}
res.set({
'Content-Type': 'image/jpeg',
});
const fileStream = createReadStream(user.profileImagePath);
return new StreamableFile(fileStream);
} catch (e) {
console.log('error getting user profile');
}
}
}

View File

@@ -1,15 +0,0 @@
import { Controller, Get, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
@Controller()
export class AppController {
constructor() { }
@Get()
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
const host = headers.host;
return res.redirect(`http://${host}:2285`)
}
}

View File

@@ -1,71 +0,0 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { databaseConfig } from './config/database.config';
import { UserModule } from './api-v1/user/user.module';
import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull';
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
import { SharingModule } from './api-v1/sharing/sharing.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
@Module({
imports: [
ConfigModule.forRoot(immichAppConfig),
TypeOrmModule.forRoot(databaseConfig),
UserModule,
AssetModule,
AuthModule,
ImmichJwtModule,
DeviceInfoModule,
BullModule.forRootAsync({
useFactory: async () => ({
redis: {
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: 6379,
},
}),
}),
ImageOptimizeModule,
ServerInfoModule,
BackgroundTaskModule,
CommunicationModule,
SharingModule,
ScheduleModule.forRoot(),
ScheduleTasksModule
],
controllers: [AppController],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
if (process.env.NODE_ENV == 'development') {
// consumer.apply(AppLoggerMiddleware).forRoutes('*');
}
}
}

View File

@@ -1,22 +0,0 @@
import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi';
export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().required().valid('development', 'production', 'staging').default('development'),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_DATABASE_NAME: Joi.string().required(),
UPLOAD_LOCATION: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
is: false,
then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(),
}),
VITE_SERVER_ENDPOINT: Joi.string().required(),
}),
};

View File

@@ -1,60 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto';
import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION;
const fileInfo = req.body as CreateAssetDto;
const yearInfo = new Date(fileInfo.createdAt).getFullYear();
const monthInfo = new Date(fileInfo.createdAt).getMonth();
if (file.fieldname == 'assetData') {
const originalUploadFolder = `${basePath}/${req.user['id']}/original/${req.body['deviceId']}`;
if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true });
}
// Save original to disk
cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${basePath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true });
}
// Save thumbnail to disk
cb(null, thumbnailUploadFolder);
}
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID();
if (file.fieldname == 'assetData') {
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
} else if (file.fieldname == 'thumbnailData') {
cb(null, `${fileNameUUID}.jpeg`);
}
},
}),
};

View File

@@ -1,19 +0,0 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: process.env.DB_HOSTNAME || 'immich_postgres',
port: 5432,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE_NAME,
entities: [__dirname + '/../**/*.entity.{js,ts}'],
synchronize: false,
migrations: [__dirname + '/../migration/*.{js,ts}'],
cli: {
migrationsDir: __dirname + '/../migration',
},
migrationsRun: true,
};
export default databaseConfig;

View File

@@ -1,7 +0,0 @@
import { JwtModuleOptions } from '@nestjs/jwt';
import { jwtSecret } from '../constants/jwt.constant';
export const jwtConfig: JwtModuleOptions = {
secret: jwtSecret,
signOptions: { expiresIn: '36500d' },
};

View File

@@ -1,40 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
}
},
storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION;
const profileImageLocation = `${basePath}/${req.user['id']}/profile`;
if (!existsSync(profileImageLocation)) {
mkdirSync(profileImageLocation, { recursive: true });
}
cb(null, profileImageLocation);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const userId = req.user['id'];
cb(null, `${userId}${extname(file.originalname)}`);
},
}),
};

View File

@@ -1 +0,0 @@
export const jwtSecret = process.env.JWT_SECRET;

View File

@@ -1,9 +0,0 @@
// major.minor.patch+build
// check mobile/pubspec.yml for current release version
export const serverVersion = {
major: 1,
minor: 10,
patch: 0,
build: 14,
};

View File

@@ -1 +0,0 @@
export const APP_UPLOAD_LOCATION = './upload';

View File

@@ -1,21 +0,0 @@
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../api-v1/user/entities/user.entity';
// import { AuthUserDto } from './dto/auth-user.dto';
export class AuthUserDto {
id: string;
email: string;
}
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
const req = ctx.switchToHttp().getRequest();
const { id, email } = req.user as UserEntity;
const authUser: any = {
id: id.toString(),
email,
};
return authUser;
});

View File

@@ -1,26 +0,0 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.set('trust proxy');
app.useWebSocketAdapter(new RedisIoAdapter(app));
await app.listen(3000, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Server in DEVELOPMENT environment', 'IMMICH SERVER');
}
if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Server in PRODUCTION environment', 'IMMICH SERVER');
}
});
}
bootstrap();

View File

@@ -1,30 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private reflector: Reflector, private jwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['authorization']) {
const bearerToken = request.headers['authorization'].split(" ")[1]
const { userId } = await this.jwtService.validateToken(bearerToken);
const user = await this.userRepository.findOne(userId);
return user.isAdmin;
}
return false;
}
}

View File

@@ -1,22 +0,0 @@
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger('HTTP');
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, path: url, baseUrl } = request;
const userAgent = request.get('user-agent') || '';
response.on('close', () => {
const { statusCode } = response;
const contentLength = response.get('content-length');
this.logger.log(`${method} ${baseUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
});
next();
}
}

View File

@@ -1,24 +0,0 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { RedisClient } from 'redis';
import { ServerOptions } from 'socket.io';
import { createAdapter } from 'socket.io-redis';
const redis_host = process.env.REDIS_HOSTNAME || 'immich_redis'
// const pubClient = createClient({ url: `redis://${redis_host}:6379` });
// const subClient = pubClient.duplicate();
const pubClient = new RedisClient({
host: redis_host,
port: 6379,
});
const subClient = pubClient.duplicate();
const redisAdapter = createAdapter({ pubClient, subClient });
export class RedisIoAdapter extends IoAdapter {
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(redisAdapter);
return server;
}
}

View File

@@ -1,22 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserTable1645130759468 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists users
(
id uuid default uuid_generate_v4() not null
constraint "PK_a3ffb1c0c8416b9fc6f907b7433"
primary key,
email varchar not null,
password varchar not null,
salt varchar not null,
"createdAt" timestamp default now() not null
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table users`);
}
}

View File

@@ -1,26 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDeviceInfoTable1645130777674 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists device_info
(
id serial
constraint "PK_b1c15a80b0a4e5f4eebadbdd92c"
primary key,
"userId" varchar not null,
"deviceId" varchar not null,
"deviceType" varchar not null,
"notificationToken" varchar,
"createdAt" timestamp default now() not null,
"isAutoBackup" boolean default false not null,
constraint "UQ_ebad78f36b10d15fbea8560e107"
unique ("userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table device_info`);
}
}

View File

@@ -1,31 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAssetsTable1645130805273 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists assets
(
id uuid default uuid_generate_v4() not null
constraint "PK_da96729a8b113377cfb6a62439c"
primary key,
"deviceAssetId" varchar not null,
"userId" varchar not null,
"deviceId" varchar not null,
type varchar not null,
"originalPath" varchar not null,
"resizePath" varchar,
"createdAt" varchar not null,
"modifiedAt" varchar not null,
"isFavorite" boolean default false not null,
"mimeType" varchar,
duration varchar,
constraint "UQ_b599ab0bd9574958acb0b30a90e"
unique ("deviceAssetId", "userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table assets`);
}
}

View File

@@ -1,42 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTable1645130817965 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists exif
(
id serial
constraint "PK_28663352d85078ad0046dafafaa"
primary key,
"assetId" uuid not null
constraint "REL_c0117fdbc50b917ef9067740c4"
unique
constraint "FK_c0117fdbc50b917ef9067740c44"
references assets
on delete cascade,
make varchar,
model varchar,
"imageName" varchar,
"exifImageWidth" integer,
"exifImageHeight" integer,
"fileSizeInByte" integer,
orientation varchar,
"dateTimeOriginal" timestamp with time zone,
"modifyDate" timestamp with time zone,
"lensModel" varchar,
"fNumber" double precision,
"focalLength" double precision,
iso integer,
"exposureTime" double precision,
latitude double precision,
longitude double precision
);
create unique index if not exists "IDX_c0117fdbc50b917ef9067740c4" on exif ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table exif`);
}
}

View File

@@ -1,30 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSmartInfoTable1645130870184 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists smart_info
(
id serial
constraint "PK_0beace66440e9713f5c40470e46"
primary key,
"assetId" uuid not null
constraint "UQ_5e3753aadd956110bf3ec0244ac"
unique
constraint "FK_5e3753aadd956110bf3ec0244ac"
references assets
on delete cascade,
tags text[]
);
create unique index if not exists "IDX_5e3753aadd956110bf3ec0244a"
on smart_info ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
drop table smart_info;
`);
}
}

View File

@@ -1,25 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddExifTextSearchColumn1646249209023 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '')
)
) STORED;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
`);
}
}

View File

@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}

View File

@@ -1,29 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRegionCityToExIf1646709533213 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ADD COLUMN if not exists city varchar;
ALTER TABLE exif
ADD COLUMN if not exists state varchar;
ALTER TABLE exif
ADD COLUMN if not exists country varchar;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN city;
ALTER TABLE exif
DROP COLUMN state;
ALTER TABLE exif
DROP COLUMN country;
`);
}
}

View File

@@ -1,37 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocationToExifTextSearch1646710459852 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", '')
)
) STORED;
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}

View File

@@ -1,18 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddObjectColumnToSmartInfo1648317474768 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
ADD COLUMN if not exists objects text[];
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
DROP COLUMN objects;
`);
}
}

View File

@@ -1,70 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSharedAlbumAndRelatedTables1649643216111 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create shared_albums
await queryRunner.query(`
create table if not exists shared_albums
(
id uuid default uuid_generate_v4() not null
constraint "PK_7f71c7b5bc7c87b8f94c9a93a00"
primary key,
"ownerId" varchar not null,
"albumName" varchar default 'Untitled Album'::character varying not null,
"createdAt" timestamp with time zone default now() not null,
"albumThumbnailAssetId" varchar
);
comment on column shared_albums."albumThumbnailAssetId" is 'Asset ID to be used as thumbnail';
`);
// Create user_shared_album
await queryRunner.query(`
create table if not exists user_shared_album
(
id serial
constraint "PK_b6562316a98845a7b3e9a25cdd0"
primary key,
"albumId" uuid not null
constraint "FK_7b3bf0f5f8da59af30519c25f18"
references shared_albums
on delete cascade,
"sharedUserId" uuid not null
constraint "FK_543c31211653e63e080ba882eb5"
references users,
constraint "PK_unique_user_in_album"
unique ("albumId", "sharedUserId")
);
`);
// Create asset_shared_album
await queryRunner.query(
`
create table if not exists asset_shared_album
(
id serial
constraint "PK_a34e076afbc601d81938e2c2277"
primary key,
"albumId" uuid not null
constraint "FK_a8b79a84996cef6ba6a3662825d"
references shared_albums
on delete cascade,
"assetId" uuid not null
constraint "FK_64f2e7d68d1d1d8417acc844a4a"
references assets
on delete cascade,
constraint "UQ_a1e2734a1ce361e7a26f6b28288"
unique ("albumId", "assetId")
);
`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
drop table asset_shared_album;
drop table user_shared_album;
drop table shared_albums;
`);
}
}

View File

@@ -1,40 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
add column if not exists "firstName" varchar default '';
alter table users
add column if not exists "lastName" varchar default '';
alter table users
add column if not exists "profileImagePath" varchar default '';
alter table users
add column if not exists "isAdmin" bool default false;
alter table users
add column if not exists "isFirstLoggedIn" bool default true;
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
drop column "firstName";
alter table users
drop column "lastName";
alter table users
drop column "isAdmin";
`);
}
}

View File

@@ -1,19 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateAssetTableWithWebpPath1653214255670 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
add column if not exists "webpPath" varchar default '';
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
drop column if exists "webpPath";
`);
}
}

View File

@@ -1,17 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
add column if not exists "encodedVideoPath" varchar default '';
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table assets
drop column if exists "encodedVideoPath";
`);
}
}

View File

@@ -1,26 +0,0 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],
})
export class BackgroundTaskModule { }

View File

@@ -1,155 +0,0 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config';
import exifr from 'exifr';
import { readFile } from 'fs/promises';
import fs from 'fs';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import axios from 'axios';
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
@Processor('background-task')
export class BackgroundTaskProcessor {
private geocodingClient: GeocodeService;
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
private configService: ConfigService,
) {
if (this.configService.get('ENABLE_MAPBOX')) {
this.geocodingClient = mapboxGeocoding({
accessToken: this.configService.get('MAPBOX_KEY'),
});
}
}
@Process('extract-exif')
async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data;
const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer);
const newExif = new ExifEntity();
newExif.assetId = savedAsset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null;
newExif.focalLength = exifData['FocalLength'] || null;
newExif.iso = exifData['ISO'] || null;
newExif.exposureTime = exifData['ExposureTime'] || null;
newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null;
// Reverse GeoCoding
if (this.configService.get('ENABLE_MAPBOX') && exifData['longitude'] && exifData['latitude']) {
const geoCodeInfo: MapiResponse = await this.geocodingClient
.reverseGeocode({
query: [exifData['longitude'], exifData['latitude']],
types: ['country', 'region', 'place'],
})
.send();
const res: [] = geoCodeInfo.body['features'];
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
newExif.city = city || null;
newExif.state = state || null;
newExif.country = country || null;
}
await this.exifRepository.save(newExif);
try {
} catch (e) {
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
}
}
@Process('delete-file-on-disk')
async deleteFileOnDisk(job) {
const { assets }: { assets: AssetEntity[] } = job.data;
for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
}
}
@Process('tag-image')
async tagImage(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-microservices:3001/image-classifier/tagImage', {
thumbnailPath: thumbnailPath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process('detect-object')
async detectObject(job) {
try {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-microservices:3001/object-detection/detectObject', {
thumbnailPath: thumbnailPath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`)
}
}
}

View File

@@ -1,57 +0,0 @@
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class BackgroundTaskService {
constructor(
@InjectQueue('background-task')
private backgroundTaskQueue: Queue,
) { }
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
await this.backgroundTaskQueue.add(
'extract-exif',
{
savedAsset,
fileName,
fileSize,
},
{ jobId: randomUUID() },
);
}
async deleteFileOnDisk(assets: AssetEntity[]) {
await this.backgroundTaskQueue.add(
'delete-file-on-disk',
{
assets,
},
{ jobId: randomUUID() },
);
}
async tagImage(thumbnailPath: string, asset: AssetEntity) {
await this.backgroundTaskQueue.add(
'tag-image',
{
thumbnailPath,
asset,
},
{ jobId: randomUUID() },
);
}
async detectObject(thumbnailPath: string, asset: AssetEntity) {
await this.backgroundTaskQueue.add(
'detect-object',
{
thumbnailPath,
asset,
},
{ jobId: randomUUID() },
);
}
}

View File

@@ -1,36 +0,0 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { CommunicationModule } from '../../api-v1/communication/communication.module';
import { BackgroundTaskModule } from '../background-task/background-task.module';
import { BackgroundTaskService } from '../background-task/background-task.service';
import { ImageOptimizeProcessor } from './image-optimize.processor';
import { AssetOptimizeService } from './image-optimize.service';
@Module({
imports: [
CommunicationModule,
BackgroundTaskModule,
BullModule.registerQueue({
name: 'optimize',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]),
],
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
exports: [AssetOptimizeService],
})
export class ImageOptimizeModule { }

View File

@@ -1,17 +0,0 @@
import { Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
import { BackgroundTaskService } from '../background-task/background-task.service';
@Processor('optimize')
export class ImageOptimizeProcessor {
constructor(
private wsCommunicateionGateway: CommunicationGateway,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private backgroundTaskService: BackgroundTaskService,
) {}
}

View File

@@ -1,10 +0,0 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'crypto';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class AssetOptimizeService {
constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {}
}

View File

@@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { ImmichJwtService } from './immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { JwtStrategy } from './strategies/jwt.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../api-v1/user/entities/user.entity';
@Module({
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity])],
providers: [ImmichJwtService, JwtStrategy],
exports: [ImmichJwtService],
})
export class ImmichJwtModule {}

View File

@@ -1,31 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
import { jwtSecret } from '../../constants/jwt.constant';
@Injectable()
export class ImmichJwtService {
constructor(private jwtService: JwtService) {}
public async generateToken(payload: JwtPayloadDto) {
return this.jwtService.sign({
...payload,
});
}
public async validateToken(accessToken: string) {
try {
const payload = await this.jwtService.verify(accessToken, { secret: jwtSecret });
return {
userId: payload['userId'],
status: true,
};
} catch (e) {
Logger.error('Error validating token from websocket request', 'ValidateWebsocketToken');
return {
userId: null,
status: false,
};
}
}
}

View File

@@ -1,33 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '../../../api-v1/user/entities/user.entity';
import { jwtSecret } from '../../../constants/jwt.constant';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
});
}
async validate(payload: JwtPayloadDto) {
const { userId } = payload;
const user = await this.usersRepository.findOne({ id: userId });
if (!user) {
throw new UnauthorizedException('Failure to validate JWT payload');
}
return user;
}
}

View File

@@ -1,52 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp';
@Injectable()
export class ImageConversionService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>
) { }
@Cron(CronExpression.EVERY_5_MINUTES
, {
name: 'webp-conversion'
})
async webpConversion() {
Logger.log('Starting Webp Conversion Tasks', 'ImageConversionService')
const assets = await this.assetRepository.find({
where: {
webpPath: ''
},
take: 500
});
if (assets.length == 0) {
Logger.log('All assets has webp file - aborting task', 'ImageConversionService')
return;
}
for (const asset of assets) {
const resizePath = asset.resizePath;
if (resizePath != '') {
const webpPath = resizePath.replace('jpeg', 'webp')
sharp(resizePath).resize(250).webp().toFile(webpPath, (err, info) => {
if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath })
}
});
}
}
}
}

View File

@@ -1,30 +0,0 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetModule } from '../../api-v1/asset/asset.module';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ImageConversionService } from './image-conversion.service';
import { VideoConversionProcessor } from './video-conversion.processor';
import { VideoConversionService } from './video-conversion.service';
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({
settings: {},
name: 'video-conversion',
limiter: {
max: 1,
duration: 60000
},
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
],
providers: [ImageConversionService, VideoConversionService, VideoConversionProcessor,],
})
export class ScheduleTasksModule { }

View File

@@ -1,56 +0,0 @@
import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { existsSync, mkdirSync } from 'fs';
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common';
@Processor('video-conversion')
export class VideoConversionProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) { }
@Process('to-mp4')
async convertToMp4(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
const basePath = APP_UPLOAD_LOCATION;
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
if (!existsSync(encodedVideoPath)) {
mkdirSync(encodedVideoPath, { recursive: true });
}
const latestAssetInfo = await this.assetRepository.findOne({ id: asset.id });
const savedEncodedPath = encodedVideoPath + "/" + latestAssetInfo.id + '.mp4'
if (latestAssetInfo.encodedVideoPath == '') {
ffmpeg(latestAssetInfo.originalPath)
.outputOptions([
'-crf 23',
'-preset ultrafast',
'-vcodec libx264',
'-acodec mp3',
'-vf scale=1280:-2'
])
.output(savedEncodedPath)
.on('start', () => Logger.log("Start Converting", 'VideoConversionMOV2MP4'))
.on('error', (a, b, c) => {
Logger.error('Cannot Convert Video', 'VideoConversionMOV2MP4')
console.log(a, b, c)
})
.on('end', async () => {
Logger.log(`Converting Success ${latestAssetInfo.id}`, 'VideoConversionMOV2MP4')
await this.assetRepository.update({ id: latestAssetInfo.id }, { encodedVideoPath: savedEncodedPath });
}).run();
}
return {}
}
}

View File

@@ -1,49 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp';
import ffmpeg from 'fluent-ffmpeg';
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
import { existsSync, mkdirSync } from 'fs';
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Queue } from 'bull';
import { randomUUID } from 'crypto';
@Injectable()
export class VideoConversionService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue('video-conversion')
private videoEncodingQueue: Queue
) { }
@Cron(CronExpression.EVERY_MINUTE
, {
name: 'video-encoding'
})
async mp4Conversion() {
const assets = await this.assetRepository.find({
where: {
type: 'VIDEO',
mimeType: 'video/quicktime',
encodedVideoPath: ''
},
order: {
createdAt: 'DESC'
},
take: 1
});
if (assets.length > 0) {
const asset = assets[0];
await this.videoEncodingQueue.add('to-mp4', { asset }, { jobId: asset.id },)
}
}
}