Compare commits

...

7 Commits

Author SHA1 Message Date
Immich Release Bot
67453d18ff Version v1.51.2 2023-03-22 21:12:45 +00:00
Michel Heusschen
792a87e407 fix(nginx): x-forwarded-* headers (#2019)
* fix(nginx): x-forwarded-* headers

* change category / add link to nginx config
2023-03-22 15:46:30 -05:00
Skyler Mäntysaari
6da50626e1 fix(server): Return the original path for gif playback (#2022)
* fix(server): Return the original path for gifs.

Usually browser is able to play them directly.

* fix(server): Better place for the condition.

* fix(server): gif viewing works properly.
2023-03-22 14:56:00 -05:00
Jason Rasmussen
6239b3b309 fix: import assets on new install (#2044) 2023-03-22 00:36:32 -05:00
Jason Rasmussen
b9bc621e2a refactor: server-info (#2038) 2023-03-21 21:49:19 -05:00
Jason Rasmussen
e10bbfa933 chore: always restart typesense (#2042) 2023-03-21 21:41:19 -05:00
Jason Rasmussen
2dd301e292 feat: show current/saved template in preset dropdown (#2040) 2023-03-21 15:19:47 -05:00
54 changed files with 702 additions and 446 deletions

View File

@@ -1,17 +0,0 @@
# Deployment checklist for iOS/Android/Server
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
All of the version should be the same.

View File

@@ -4,7 +4,7 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:release
entrypoint: [ "/bin/sh", "./start-server.sh" ]
entrypoint: ["/bin/sh", "./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -20,7 +20,7 @@ services:
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:release
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@@ -48,7 +48,7 @@ services:
immich-web:
container_name: immich_web
image: ghcr.io/immich-app/immich-web:release
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
restart: always
@@ -63,6 +63,7 @@ services:
driver: none
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis

View File

@@ -0,0 +1,22 @@
# Reverse Proxy
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
## Default Reverse Proxy
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
## Using a Different Reverse Proxy
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
1. Add another reverse proxy on top of Immich's reverse proxy
2. Completely replace the default reverse proxy
## Adding a Custom Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
## Replacing the Default Reverse Proxy
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.

View File

@@ -36,7 +36,7 @@ platform :android do
build_type: 'Release',
properties: {
"android.injected.version.code" => 74,
"android.injected.version.name" => "1.51.1",
"android.injected.version.name" => "1.51.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.51.1"
version_number: "1.51.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.51.1+74
version: 1.51.2+74
isar_version: &isar_version 3.0.5
environment:

View File

@@ -3,6 +3,14 @@ map $http_upgrade $connection_upgrade {
'' close;
}
map $http_x_forwarded_proto $forwarded_protocol {
default $scheme;
# Only allow the values 'http' and 'https' for the X-Forwarded-Proto header.
http http;
https https;
}
upstream server {
server ${IMMICH_SERVER_HOST};
keepalive 2;
@@ -43,13 +51,12 @@ server {
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
@@ -64,13 +71,12 @@ server {
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $forwarded_protocol;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_pass ${IMMICH_WEB_SCHEME}web;
}

View File

@@ -279,17 +279,20 @@ export class AssetService {
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
if (query.isWeb && asset.mimeType != 'image/gif') {
res.set({
'Content-Type': 'image/jpeg',
});
if (!asset.resizePath) {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -299,7 +302,7 @@ export class AssetService {
/**
* Serve thumbnail image for both web and mobile app
*/
if (!query.isThumb && allowOriginalFile) {
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) {
res.set({
'Content-Type': asset.mimeType,
});

View File

@@ -1,36 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant';
import { ApiTags } from '@nestjs/swagger';
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { Authenticated } from '../../decorators/authenticated.decorator';
@ApiTags('Server Info')
@Controller('server-info')
export class ServerInfoController {
constructor(private readonly serverInfoService: ServerInfoService) {}
@Get()
async getServerInfo(): Promise<ServerInfoResponseDto> {
return await this.serverInfoService.getServerInfo();
}
@Get('/ping')
async pingServer(): Promise<ServerPingResponse> {
return new ServerPingResponse('pong');
}
@Get('/version')
async getServerVersion(): Promise<ServerVersionReponseDto> {
return serverVersion;
}
@Authenticated({ admin: true })
@Get('/stats')
async getStats(): Promise<ServerStatsResponseDto> {
return await this.serverInfoService.getStats();
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { ServerInfoController } from './server-info.controller';
import { UserEntity } from '@app/infra';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [ServerInfoController],
providers: [ServerInfoService],
})
export class ServerInfoModule {}

View File

@@ -1,81 +0,0 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { Injectable } from '@nestjs/common';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import diskusage from 'diskusage';
import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
import { UserEntity } from '@app/infra';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { asHumanReadable } from '../../utils/human-readable.util';
@Injectable()
export class ServerInfoService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async getServerInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerInfoResponseDto();
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
serverInfo.diskSize = asHumanReadable(diskInfo.total);
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
async getStats(): Promise<ServerStatsResponseDto> {
type UserStatsQueryResponse = {
userId: string;
userFirstName: string;
userLastName: string;
photos: string;
videos: string;
usage: string;
};
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
const usageByUser = userStatsQueryResponse.map((userStats) => {
const usage = new UsageByUserDto();
usage.userId = userStats.userId;
usage.userFirstName = userStats.userFirstName;
usage.userLastName = userStats.userLastName;
usage.photos = Number(userStats.photos);
usage.videos = Number(userStats.videos);
usage.usage = Number(userStats.usage);
return usage;
});
const serverStats = new ServerStatsResponseDto();
usageByUser.forEach((user) => {
serverStats.photos += user.photos;
serverStats.videos += user.videos;
serverStats.usage += user.usage;
});
serverStats.usageByUser = usageByUser;
return serverStats;
}
}

View File

@@ -2,7 +2,6 @@ import { immichAppConfig } from '@app/common/config';
import { Module, OnModuleInit } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { AlbumModule } from './api-v1/album/album.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
@@ -17,6 +16,7 @@ import {
JobController,
OAuthController,
SearchController,
ServerInfoController,
ShareController,
SystemConfigController,
UserController,
@@ -34,8 +34,6 @@ import { AuthGuard } from './middlewares/auth.guard';
AssetModule,
ServerInfoModule,
AlbumModule,
ScheduleModule.forRoot(),
@@ -52,6 +50,7 @@ import { AuthGuard } from './middlewares/auth.guard';
JobController,
OAuthController,
SearchController,
ServerInfoController,
ShareController,
SystemConfigController,
UserController,

View File

@@ -1,4 +1,4 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { createHash, randomUUID } from 'crypto';

View File

@@ -1,4 +1,4 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { Request } from 'express';

View File

@@ -4,6 +4,7 @@ export * from './device-info.controller';
export * from './job.controller';
export * from './oauth.controller';
export * from './search.controller';
export * from './server-info.controller';
export * from './share.controller';
export * from './system-config.controller';
export * from './user.controller';

View File

@@ -0,0 +1,37 @@
import {
ServerInfoResponseDto,
ServerInfoService,
ServerPingResponse,
ServerStatsResponseDto,
ServerVersionReponseDto,
} from '@app/domain';
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('Server Info')
@Controller('server-info')
export class ServerInfoController {
constructor(private readonly service: ServerInfoService) {}
@Get()
getServerInfo(): Promise<ServerInfoResponseDto> {
return this.service.getInfo();
}
@Get('/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('/version')
getServerVersion(): ServerVersionReponseDto {
return this.service.getVersion();
}
@Authenticated({ admin: true })
@Get('/stats')
getStats(): Promise<ServerStatsResponseDto> {
return this.service.getStats();
}
}

View File

@@ -6,12 +6,11 @@ import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs';
import path from 'path';
import { AppModule } from './app.module';
import { SERVER_VERSION } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
import { SERVER_VERSION, IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
const logger = new Logger('ImmichServer');
@@ -20,7 +19,7 @@ async function bootstrap() {
logger: getLogLevels(),
});
app.set('trust proxy');
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));

View File

@@ -2,7 +2,7 @@ import { AssetEntity } from '@app/infra';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import archiver from 'archiver';
import { extname } from 'path';
import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util';
import { asHumanReadable, HumanReadableSize } from '@app/domain';
export interface DownloadArchive {
stream: StreamableFile;

View File

@@ -1,6 +1,6 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SERVER_VERSION } from 'apps/immich/src/constants/server_version.constant';
import { SERVER_VERSION } from '@app/domain';
import { getLogLevels } from '@app/common';
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
import { MicroservicesModule } from './microservices.module';

View File

@@ -1,6 +1,5 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity, AssetType } from '@app/infra';
import {
APP_UPLOAD_LOCATION,
IAssetJob,
IAssetRepository,
IBaseJob,
@@ -10,6 +9,7 @@ import {
SystemConfigService,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra';
import { Process, Processor } from '@nestjs/bull';
import { Inject, Logger } from '@nestjs/common';
import { Job } from 'bull';

View File

@@ -834,6 +834,102 @@
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfoResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerPingResponse"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/version": {
"get": {
"operationId": "getServerVersion",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerVersionReponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/stats": {
"get": {
"operationId": "getStats",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatsResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/share": {
"get": {
"operationId": "getAllSharedLinks",
@@ -3270,108 +3366,12 @@
}
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfoResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerPingResponse"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/version": {
"get": {
"operationId": "getServerVersion",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerVersionReponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/stats": {
"get": {
"operationId": "getStats",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatsResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
}
},
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.51.1",
"version": "1.51.2",
"contact": {}
},
"tags": [],
@@ -4330,6 +4330,148 @@
"items"
]
},
"ServerInfoResponseDto": {
"type": "object",
"properties": {
"diskSizeRaw": {
"type": "integer",
"format": "int64"
},
"diskUseRaw": {
"type": "integer",
"format": "int64"
},
"diskAvailableRaw": {
"type": "integer",
"format": "int64"
},
"diskUsagePercentage": {
"type": "number",
"format": "float"
},
"diskSize": {
"type": "string"
},
"diskUse": {
"type": "string"
},
"diskAvailable": {
"type": "string"
}
},
"required": [
"diskSizeRaw",
"diskUseRaw",
"diskAvailableRaw",
"diskUsagePercentage",
"diskSize",
"diskUse",
"diskAvailable"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
"res": {
"type": "string",
"readOnly": true,
"example": "pong"
}
},
"required": [
"res"
]
},
"ServerVersionReponseDto": {
"type": "object",
"properties": {
"major": {
"type": "integer"
},
"minor": {
"type": "integer"
},
"patch": {
"type": "integer"
}
},
"required": [
"major",
"minor",
"patch"
]
},
"UsageByUserDto": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"userFirstName": {
"type": "string"
},
"userLastName": {
"type": "string"
},
"photos": {
"type": "integer"
},
"videos": {
"type": "integer"
},
"usage": {
"type": "integer",
"format": "int64"
}
},
"required": [
"userId",
"userFirstName",
"userLastName",
"photos",
"videos",
"usage"
]
},
"ServerStatsResponseDto": {
"type": "object",
"properties": {
"photos": {
"type": "integer",
"default": 0
},
"videos": {
"type": "integer",
"default": 0
},
"usage": {
"type": "integer",
"default": 0,
"format": "int64"
},
"usageByUser": {
"default": [],
"title": "Array of usage for each user",
"example": [
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/UsageByUserDto"
}
}
},
"required": [
"photos",
"videos",
"usage",
"usageByUser"
]
},
"SharedLinkType": {
"type": "string",
"enum": [
@@ -5271,148 +5413,6 @@
"required": [
"albumId"
]
},
"ServerInfoResponseDto": {
"type": "object",
"properties": {
"diskSizeRaw": {
"type": "integer",
"format": "int64"
},
"diskUseRaw": {
"type": "integer",
"format": "int64"
},
"diskAvailableRaw": {
"type": "integer",
"format": "int64"
},
"diskUsagePercentage": {
"type": "number",
"format": "float"
},
"diskSize": {
"type": "string"
},
"diskUse": {
"type": "string"
},
"diskAvailable": {
"type": "string"
}
},
"required": [
"diskSizeRaw",
"diskUseRaw",
"diskAvailableRaw",
"diskUsagePercentage",
"diskSize",
"diskUse",
"diskAvailable"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
"res": {
"type": "string",
"readOnly": true,
"example": "pong"
}
},
"required": [
"res"
]
},
"ServerVersionReponseDto": {
"type": "object",
"properties": {
"major": {
"type": "integer"
},
"minor": {
"type": "integer"
},
"patch": {
"type": "integer"
}
},
"required": [
"major",
"minor",
"patch"
]
},
"UsageByUserDto": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"userFirstName": {
"type": "string"
},
"userLastName": {
"type": "string"
},
"photos": {
"type": "integer"
},
"videos": {
"type": "integer"
},
"usage": {
"type": "integer",
"format": "int64"
}
},
"required": [
"userId",
"userFirstName",
"userLastName",
"photos",
"videos",
"usage"
]
},
"ServerStatsResponseDto": {
"type": "object",
"properties": {
"photos": {
"type": "integer",
"default": 0
},
"videos": {
"type": "integer",
"default": 0
},
"usage": {
"type": "integer",
"default": 0,
"format": "int64"
},
"usageByUser": {
"default": [],
"title": "Array of usage for each user",
"example": [
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/UsageByUserDto"
}
}
},
"required": [
"photos",
"videos",
"usage",
"usageByUser"
]
}
}
}

View File

@@ -1,7 +1,5 @@
import { BadRequestException } from '@nestjs/common';
export * from './upload_location.constant';
export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';

View File

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

View File

@@ -1,4 +1,4 @@
import pkg from 'package.json';
import pkg from '../../../package.json';
const [major, minor, patch] = pkg.version.split('.');
@@ -15,3 +15,5 @@ export const serverVersion: IServerVersion = {
};
export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
export const APP_UPLOAD_LOCATION = './upload';

View File

@@ -7,6 +7,7 @@ import { JobService } from './job';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { SearchService } from './search';
import { ServerInfoService } from './server-info';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
@@ -22,6 +23,7 @@ const providers: Provider[] = [
JobService,
MediaService,
OAuthService,
ServerInfoService,
SmartInfoService,
StorageService,
StorageTemplateService,

View File

@@ -1,3 +1,9 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}
const KiB = Math.pow(1024, 1);
const MiB = Math.pow(1024, 2);
const GiB = Math.pow(1024, 3);

View File

@@ -5,11 +5,14 @@ export * from './auth';
export * from './communication';
export * from './crypto';
export * from './device-info';
export * from './domain.constant';
export * from './domain.module';
export * from './domain.util';
export * from './job';
export * from './media';
export * from './oauth';
export * from './search';
export * from './server-info';
export * from './share';
export * from './smart-info';
export * from './storage';
@@ -18,4 +21,3 @@ export * from './system-config';
export * from './tag';
export * from './user';
export * from './user-token';
export * from './util';

View File

@@ -1,10 +1,10 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetType } from '@app/infra/db/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { join } from 'path';
import sanitize from 'sanitize-filename';
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { IMediaRepository } from './media.repository';

View File

@@ -173,12 +173,23 @@ describe(SearchService.name, () => {
});
describe('handleIndexAssets', () => {
it('should call done, even when there are no assets', async () => {
assetMock.getAll.mockResolvedValue([]);
await sut.handleIndexAssets();
expect(searchMock.importAssets).toHaveBeenCalledWith([], true);
});
it('should index all the assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
await sut.handleIndexAssets();
expect(searchMock.importAssets).toHaveBeenCalledWith([assetEntityStub.image], true);
expect(searchMock.importAssets.mock.calls).toEqual([
[[assetEntityStub.image], false],
[[], true],
]);
});
it('should log an error', async () => {

View File

@@ -148,12 +148,11 @@ export class SearchService {
const chunkSize = 1000;
for (let i = 0; i < assets.length; i += chunkSize) {
const end = i + chunkSize;
const chunk = assets.slice(i, end);
const done = end >= assets.length - 1;
await this.searchRepository.importAssets(chunk, done);
await this.searchRepository.importAssets(assets.slice(i, i + chunkSize), false);
}
await this.searchRepository.importAssets([], true);
this.logger.debug('Finished re-indexing all assets');
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack);

View File

@@ -0,0 +1,2 @@
export * from './response-dto';
export * from './server-info.service';

View File

@@ -0,0 +1,5 @@
export * from './server-info-response.dto';
export * from './server-ping-response.dto';
export * from './server-stats-response.dto';
export * from './server-version-response.dto';
export * from './usage-by-user-response.dto';

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IServerVersion } from 'apps/immich/src/constants/server_version.constant';
import { IServerVersion } from '@app/domain';
export class ServerVersionReponseDto implements IServerVersion {
@ApiProperty({ type: 'integer' })

View File

@@ -0,0 +1,209 @@
import { newStorageRepositoryMock, newUserRepositoryMock } from '../../test';
import { serverVersion } from '../domain.constant';
import { IStorageRepository } from '../storage';
import { IUserRepository } from '../user';
import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new ServerInfoService(userMock, storageMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getInfo', () => {
it('should return the disk space as B', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '300 B',
diskAvailableRaw: 300,
diskSize: '500 B',
diskSizeRaw: 500,
diskUsagePercentage: 60,
diskUse: '300 B',
diskUseRaw: 300,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as KiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '293.0 KiB',
diskAvailableRaw: 300000,
diskSize: '488.3 KiB',
diskSizeRaw: 500000,
diskUsagePercentage: 60,
diskUse: '293.0 KiB',
diskUseRaw: 300000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as MiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 });
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '286.1 MiB',
diskAvailableRaw: 300000000,
diskSize: '476.8 MiB',
diskSizeRaw: 500000000,
diskUsagePercentage: 60,
diskUse: '286.1 MiB',
diskUseRaw: 300000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as GiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000,
available: 300_000_000_000,
total: 500_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '279.4 GiB',
diskAvailableRaw: 300000000000,
diskSize: '465.7 GiB',
diskSizeRaw: 500000000000,
diskUsagePercentage: 60,
diskUse: '279.4 GiB',
diskUseRaw: 300000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as TiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000,
available: 300_000_000_000_000,
total: 500_000_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '272.8 TiB',
diskAvailableRaw: 300000000000000,
diskSize: '454.7 TiB',
diskSizeRaw: 500000000000000,
diskUsagePercentage: 60,
diskUse: '272.8 TiB',
diskUseRaw: 300000000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
it('should return the disk space as PiB', async () => {
storageMock.checkDiskUsage.mockResolvedValue({
free: 200_000_000_000_000_000,
available: 300_000_000_000_000_000,
total: 500_000_000_000_000_000,
});
await expect(sut.getInfo()).resolves.toEqual({
diskAvailable: '266.5 PiB',
diskAvailableRaw: 300000000000000000,
diskSize: '444.1 PiB',
diskSizeRaw: 500000000000000000,
diskUsagePercentage: 60,
diskUse: '266.5 PiB',
diskUseRaw: 300000000000000000,
});
expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
});
});
describe('ping', () => {
it('should respond with pong', () => {
expect(sut.ping()).toEqual({ res: 'pong' });
});
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('getStats', () => {
it('should total up usage by user', async () => {
userMock.getUserStats.mockResolvedValue([
{
userId: 'user1',
userFirstName: '1',
userLastName: 'User',
photos: 10,
videos: 11,
usage: 12345,
},
{
userId: 'user2',
userFirstName: '2',
userLastName: 'User',
photos: 10,
videos: 20,
usage: 123456,
},
{
userId: 'user3',
userFirstName: '3',
userLastName: 'User',
photos: 100,
videos: 0,
usage: 987654,
},
]);
await expect(sut.getStats()).resolves.toEqual({
photos: 120,
videos: 31,
usage: 1123455,
usageByUser: [
{
photos: 10,
usage: 12345,
userFirstName: '1',
userId: 'user1',
userLastName: 'User',
videos: 11,
},
{
photos: 10,
usage: 123456,
userFirstName: '2',
userId: 'user2',
userLastName: 'User',
videos: 20,
},
{
photos: 100,
usage: 987654,
userFirstName: '3',
userId: 'user3',
userLastName: 'User',
videos: 0,
},
],
});
expect(userMock.getUserStats).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_UPLOAD_LOCATION, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import { IStorageRepository } from '../storage';
import { IUserRepository, UserStatsQueryResponse } from '../user';
import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
@Injectable()
export class ServerInfoService {
constructor(
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {}
async getInfo(): Promise<ServerInfoResponseDto> {
const diskInfo = await this.storageRepository.checkDiskUsage(APP_UPLOAD_LOCATION);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerInfoResponseDto();
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
serverInfo.diskSize = asHumanReadable(diskInfo.total);
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo;
}
ping(): ServerPingResponse {
return new ServerPingResponse('pong');
}
getVersion() {
return serverVersion;
}
async getStats(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();
for (const user of userStats) {
const usage = new UsageByUserDto();
usage.userId = user.userId;
usage.userFirstName = user.userFirstName;
usage.userLastName = user.userLastName;
usage.photos = user.photos;
usage.videos = user.videos;
usage.usage = user.usage;
serverStats.photos += usage.photos;
serverStats.videos += usage.videos;
serverStats.usage += usage.usage;
serverStats.usageByUser.push(usage);
}
return serverStats;
}
}

View File

@@ -1,4 +1,3 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import {
IStorageRepository,
ISystemConfigRepository,
@@ -15,6 +14,7 @@ import handlebar from 'handlebars';
import * as luxon from 'luxon';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { SystemConfigCore } from '../system-config/system-config.core';
export class StorageTemplateCore {

View File

@@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository } from '../asset/asset.repository';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IStorageRepository } from '../storage/storage.repository';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { StorageTemplateCore } from './storage-template.core';

View File

@@ -6,6 +6,12 @@ export interface ImmichReadStream {
length: number;
}
export interface DiskUsage {
available: number;
free: number;
total: number;
}
export const IStorageRepository = 'IStorageRepository';
export interface IStorageRepository {
@@ -16,4 +22,5 @@ export interface IStorageRepository {
moveFile(source: string, target: string): Promise<void>;
checkFileExists(filepath: string): Promise<boolean>;
mkdirSync(filepath: string): void;
checkDiskUsage(folder: string): Promise<DiskUsage>;
}

View File

@@ -4,6 +4,15 @@ export interface UserListFilter {
excludeId?: string;
}
export interface UserStatsQueryResponse {
userId: string;
userFirstName: string;
userLastName: string;
photos: number;
videos: number;
usage: number;
}
export const IUserRepository = 'IUserRepository';
export interface IUserRepository {
@@ -13,6 +22,7 @@ export interface IUserRepository {
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>;
getUserStats(): Promise<UserStatsQueryResponse[]>;
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;

View File

@@ -3,12 +3,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } fr
import { randomBytes } from 'crypto';
import { ReadStream } from 'fs';
import { join } from 'path';
import { APP_UPLOAD_LOCATION } from '@app/common';
import { IAlbumRepository } from '../album/album.repository';
import { IKeyRepository } from '../api-key/api-key.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { APP_UPLOAD_LOCATION } from '../domain.constant';
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
import { IStorageRepository } from '../storage/storage.repository';
import { IUserTokenRepository } from '../user-token/user-token.repository';

View File

@@ -1,5 +0,0 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}

View File

@@ -9,5 +9,6 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
moveFile: jest.fn(),
checkFileExists: jest.fn(),
mkdirSync: jest.fn(),
checkDiskUsage: jest.fn(),
};
};

View File

@@ -6,6 +6,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
getAdmin: jest.fn(),
getByEmail: jest.fn(),
getByOAuthId: jest.fn(),
getUserStats: jest.fn(),
getList: jest.fn(),
create: jest.fn(),
update: jest.fn(),

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '../entities';
import { IUserRepository, UserListFilter } from '@app/domain';
import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
@@ -76,4 +76,28 @@ export class UserRepository implements IUserRepository {
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
.createQueryBuilder('users')
.select('users.id', 'userId')
.addSelect('users.firstName', 'userFirstName')
.addSelect('users.lastName', 'userLastName')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
.orderBy('users.createdAt', 'ASC')
.getRawMany();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
}
return stats;
}
}

View File

@@ -1,8 +1,9 @@
import { ImmichReadStream, IStorageRepository } from '@app/domain';
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises';
import mv from 'mv';
import { promisify } from 'node:util';
import diskUsage from 'diskusage';
import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
@@ -66,4 +67,8 @@ export class FilesystemProvider implements IStorageRepository {
mkdirSync(filepath, { recursive: true });
}
}
checkDiskUsage(folder: string): Promise<DiskUsage> {
return diskUsage.check(folder);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.51.1",
"version": "1.51.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.50.1",
"version": "1.51.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.51.1",
"version": "1.51.2",
"description": "",
"author": "",
"private": true,
@@ -129,7 +129,7 @@
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s",
@@ -137,10 +137,6 @@
],
"coverageDirectory": "./coverage",
"coverageThreshold": {
"global": {
"lines": 17,
"statements": 17
},
"./libs/domain/": {
"branches": 80,
"functions": 85,

View File

@@ -18,8 +18,6 @@
"paths": {
"@app/common": ["libs/common/src"],
"@app/common/*": ["libs/common/src/*"],
"@app/storage": ["libs/storage/src"],
"@app/storage/*": ["libs/storage/src/*"],
"@app/infra": ["libs/infra/src"],
"@app/infra/*": ["libs/infra/src/*"],
"@app/domain": ["libs/domain/src"],

View File

@@ -4,6 +4,8 @@
export PUBLIC_IMMICH_SERVER_URL=$IMMICH_SERVER_URL
export PUBLIC_IMMICH_API_URL_EXTERNAL=$IMMICH_API_URL_EXTERNAL
export PROTOCOL_HEADER=X-Forwarded-Proto
if [ "$(id -u)" -eq 0 ] && [ -n "$PUID" ] && [ -n "$PGID" ]; then
exec setpriv --reuid "$PUID" --regid "$PGID" --clear-groups node /usr/src/app/build/index.js
else

View File

@@ -34,7 +34,7 @@
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
]);
selectedPreset = templateOptions.presetOptions[0];
selectedPreset = savedConfig.template;
}
const getSupportDateTimeFormat = async () => {