Merge branch 'main' into main

This commit is contained in:
Alex
2024-07-08 22:46:30 -05:00
committed by GitHub
88 changed files with 4255 additions and 2976 deletions

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240702@sha256:5d675b67826ac643ee64ecf2ef78adac1e491eef9a845f30818a1c0d1338ecc8 as dev
FROM ghcr.io/immich-app/base-server-dev:20240708@sha256:2a9e3231c34493cb861299d475c84c031e7f04519dbc895bbebb5017d479a3cb as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -41,7 +41,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240702@sha256:419a873052cf2f012ed1977e4a771a38e68ce64ea1c66047cc06232b1a79bafe
FROM ghcr.io/immich-app/base-server-prod:20240708@sha256:0af3a5bb036c9a4b6a5a51becaa6e94fe182f6bc97480d57e8f2e6f994bfa453
WORKDIR /usr/src/app
ENV NODE_ENV=production \
@@ -50,7 +50,7 @@ ENV NODE_ENV=production \
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin
COPY --from=web /usr/src/app/build ./www
COPY --from=web /usr/src/app/build /build/www
COPY server/resources resources
COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./

3066
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.52.0",
"@opentelemetry/sdk-node": "^0.52.0",
"@react-email/components": "^0.0.19",
"@react-email/components": "^0.0.21",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@@ -121,7 +121,7 @@
"eslint-plugin-unicorn": "^54.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^3.2.3",
"prettier-plugin-organize-imports": "^4.0.0",
"rimraf": "^5.0.1",
"source-map-support": "^0.5.21",
"sql-formatter": "^15.0.0",

View File

@@ -27,13 +27,28 @@ export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www';
const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283';
export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT;
const GEODATA_ROOT_PATH = process.env.IMMICH_REVERSE_GEOCODING_ROOT || '/usr/src/resources';
export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const buildFolder = process.env.IMMICH_BUILD_DATA || '/build';
const folders = {
geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'),
};
export const resourcePaths = {
lockFile: join(buildFolder, 'build-lock.json'),
geodata: {
dateFile: join(folders.geodata, 'geodata-date.txt'),
admin1: join(folders.geodata, 'admin1CodesASCII.txt'),
admin2: join(folders.geodata, 'admin2Codes.txt'),
cities500: join(folders.geodata, citiesFile),
},
web: {
root: folders.web,
indexHtml: join(folders.web, 'index.html'),
},
};
export const MOBILE_REDIRECT = 'app.immich:/';
export const LOGIN_URL = '/auth/login?autoLaunch=0';

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerDirection } from 'src/interfaces/partner.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
@@ -16,8 +16,8 @@ export class PartnerController {
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
@Authenticated()
// TODO: remove 'direction' and convert to full query dto
getPartners(@Auth() auth: AuthDto, @Query('direction') direction: PartnerDirection): Promise<PartnerResponseDto[]> {
return this.service.getAll(auth, direction);
getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise<PartnerResponseDto[]> {
return this.service.search(auth, dto);
}
@Post(':id')

View File

@@ -1,11 +1,19 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
import { UserResponseDto } from 'src/dtos/user.dto';
import { PartnerDirection } from 'src/interfaces/partner.interface';
export class UpdatePartnerDto {
@IsNotEmpty()
inTimeline!: boolean;
}
export class PartnerSearchDto {
@IsEnum(PartnerDirection)
@ApiProperty({ enum: PartnerDirection, enumName: 'PartnerDirection' })
direction!: PartnerDirection;
}
export class PartnerResponseDto extends UserResponseDto {
inTimeline?: boolean;
}

View File

@@ -140,7 +140,7 @@ export class UserAdminResponseDto extends UserResponseDto {
}
export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
const license = entity.metadata.find(
const license = entity.metadata?.find(
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
)?.value;
return {

View File

@@ -4,7 +4,7 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import readLine from 'node:readline';
import { citiesFile, geodataAdmin1Path, geodataAdmin2Path, geodataCities500Path, geodataDatePath } from 'src/constants';
import { citiesFile, resourcePaths } from 'src/constants';
import { AssetEntity } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
@@ -37,7 +37,7 @@ export class MapRepository implements IMapRepository {
async init(): Promise<void> {
this.logger.log('Initializing metadata repository');
const geodataDate = await readFile(geodataDatePath, 'utf8');
const geodataDate = await readFile(resourcePaths.geodata.dateFile, 'utf8');
// TODO move to service init
const geocodingMetadata = await this.metadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
@@ -150,8 +150,8 @@ export class MapRepository implements IMapRepository {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
const admin1 = await this.loadAdmin(geodataAdmin1Path);
const admin2 = await this.loadAdmin(geodataAdmin2Path);
const admin1 = await this.loadAdmin(resourcePaths.geodata.admin1);
const admin2 = await this.loadAdmin(resourcePaths.geodata.admin2);
try {
await queryRunner.startTransaction();
@@ -221,7 +221,7 @@ export class MapRepository implements IMapRepository {
admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
geodataCities500Path,
resourcePaths.geodata.cities500,
{ entityFilter: (lineSplit) => lineSplit[7] != 'PPLX' },
);
}

View File

@@ -4,6 +4,7 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { resourcePaths } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@@ -61,9 +62,9 @@ export class ServerInfoRepository implements IServerInfoRepository {
maybeFirstLine('convert --version'),
]);
const lockfile = await readFile('build-lock.json')
const lockfile = await readFile(resourcePaths.lockFile)
.then((buffer) => JSON.parse(buffer.toString()))
.catch(() => this.logger.warn('Failed to read build-lock.json'));
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
return {
nodejs: nodejsOutput || process.env.NODE_VERSION || '',

View File

@@ -202,7 +202,7 @@ describe(StorageRepository.name, () => {
.filter((entry) => entry[1])
.map(([file]) => file);
expect(actual.sort()).toEqual(expected.sort());
expect(actual.toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@@ -2,8 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Cron, CronExpression, Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ONE_HOUR, WEB_ROOT } from 'src/constants';
import { ONE_HOUR, resourcePaths } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService } from 'src/services/auth.service';
import { JobService } from 'src/services/job.service';
@@ -56,9 +55,9 @@ export class ApiService {
ssr(excludePaths: string[]) {
let index = '';
try {
index = readFileSync(join(WEB_ROOT, 'index.html')).toString();
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn('Unable to open `www/index.html, skipping SSR.');
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return async (request: Request, res: Response, next: NextFunction) => {

View File

@@ -255,13 +255,11 @@ describe(AssetMediaService.name, () => {
}
it('should be sorted (valid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(valid).toEqual([...valid].sort());
expect(valid).toEqual(valid.toSorted());
});
it('should be sorted (invalid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(invalid).toEqual([...invalid].sort());
expect(invalid).toEqual(invalid.toSorted());
});
});
}

View File

@@ -21,16 +21,16 @@ describe(PartnerService.name, () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
describe('search', () => {
it("should return a list of partners with whom I've shared my library", async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toBeDefined();
await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
it('should return a list of partners who have shared their libraries with me', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toBeDefined();
await expect(sut.search(authStub.user1, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { PartnerEntity } from 'src/entities/partner.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
@@ -38,7 +38,7 @@ export class PartnerService {
await this.repository.remove(partner);
}
async getAll(auth: AuthDto, direction: PartnerDirection): Promise<PartnerResponseDto[]> {
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
const partners = await this.repository.getAll(auth.user.id);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners

View File

@@ -145,7 +145,7 @@ describe('mimeTypes', () => {
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual([...keys].sort());
expect(keys).toEqual(keys.toSorted());
});
it('should contain only video mime types', () => {
@@ -171,7 +171,7 @@ describe('mimeTypes', () => {
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual([...keys].sort());
expect(keys).toEqual(keys.toSorted());
});
it('should contain only xml mime types', () => {

View File

@@ -5,7 +5,7 @@ import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { ApiModule } from 'src/app.module';
import { envName, excludePaths, isDev, serverVersion, WEB_ROOT } from 'src/constants';
import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ApiService } from 'src/services/api.service';
@@ -38,11 +38,11 @@ async function bootstrap() {
useSwagger(app);
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(WEB_ROOT)) {
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(WEB_ROOT, {
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,

View File

@@ -10,6 +10,7 @@
"resolveJsonModule": true,
"target": "es2022",
"moduleResolution": "node16",
"lib": ["dom", "es2023"],
"sourceMap": true,
"outDir": "./dist",
"incremental": true,