first commit

This commit is contained in:
Alex Tran
2026-02-06 23:08:40 -06:00
commit 056850d115
399 changed files with 45789 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
**/node_modules/**
.DS_Store
.vscode/*
!.vscode/launch.json
!.vscode/extensions.json
.idea
docker/upload
docker/library
uploads
coverage
node_modules
docker/app-data
open-api/typescript-sdk/build
vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Immich
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
DOCKER_COMPOSE_FILE := docker/docker-compose.yml
.PHONY: dev dev-down open-api open-api-dart open-api-typescript \
build-server build-web test-server test-medium test-e2e \
lint-server lint-web format
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
# OpenAPI SDK generation
open-api:
cd open-api && ./bin/generate-open-api.sh
# Build targets
build-server:
pnpm --filter immich build
build-web:
pnpm --filter web build
# Test targets
test-server:
pnpm --filter immich test
test-medium:
pnpm --filter immich test:medium
test-e2e:
pnpm --filter immich-e2e test
# Lint & format
lint-server:
pnpm --filter immich lint
lint-web:
pnpm --filter web lint
format:
pnpm format
# Install dependencies
install:
pnpm install
# Clean everything
clean:
docker compose -f $(DOCKER_COMPOSE_FILE) down -v --remove-orphans
rm -rf node_modules server/node_modules web/node_modules e2e/node_modules
+52
View File
@@ -0,0 +1,52 @@
# Core Monorepo
A full-stack monorepo template with **NestJS** (server), **SvelteKit** (web) powered by **pnpm workspaces**.
## Architecture
```
server/ NestJS REST API with PostgreSQL + Redis
web/ SvelteKit frontend (static adapter)
open-api/ OpenAPI spec generation & TypeScript SDK
i18n/ Internationalization strings
docker/ Docker Compose for local development
```
## Quick Start
```bash
# Install dependencies
pnpm install
# Start Postgres + Redis
make dev
# Or individually:
make dev-down # Stop services
make build-server # Build NestJS server
make build-web # Build SvelteKit app
make open-api # Regenerate OpenAPI spec & SDK
```
## Development
| Command | Description |
| ------------------- | ---------------------------------------- |
| `make dev` | Start Docker services + web + server |
| `make dev-down` | Stop all services |
| `make build-server` | Build the NestJS server |
| `make build-web` | Build the SvelteKit web app |
| `make open-api` | Regenerate OpenAPI spec & SDKs |
| `make test-server` | Run server unit tests |
| `make test-e2e` | Run end-to-end tests |
| `make lint-server` | Lint the server |
| `make lint-web` | Lint the web app |
| `make clean` | Remove containers, volumes, node_modules |
## Stack
- **Server**: NestJS, Kysely (query builder), PostgreSQL 18, Redis
- **Web**: SvelteKit 2, Svelte 5, TailwindCSS, @immich/ui
- **API**: Auto-generated OpenAPI spec with TypeScript SDK via oazapfts
- **Auth**: JWT (access + refresh tokens), API keys, session management
- **i18n**: svelte-i18n with English base locale
+11
View File
@@ -0,0 +1,11 @@
APP_DATA_LOCATION=./app-data
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immichtemplate
+23
View File
@@ -0,0 +1,23 @@
# dev build
FROM node:24-bookworm-slim AS dev
RUN apt-get update && \
apt-get install -y --no-install-recommends \
tini git build-essential python3 curl && \
rm -rf /var/lib/apt/lists/*
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
RUN corepack enable pnpm
WORKDIR /usr/src/app
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
IMMICH_ENV=development
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
+96
View File
@@ -0,0 +1,96 @@
name: immichtemplate-dev
services:
server:
container_name: immichtemplate_server
command: ["run-server"]
image: immichtemplate-server-dev:latest
build:
context: ../
dockerfile: docker/Dockerfile.dev
target: dev
restart: unless-stopped
volumes:
- ..:/usr/src/app
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
env_file:
- .env
ports:
- 9230:9230
- 9231:9231
- 2283:2283
depends_on:
redis:
condition: service_started
database:
condition: service_healthy
web:
container_name: immichtemplate_web
image: immichtemplate-web-dev:latest
build:
context: ../
dockerfile: docker/Dockerfile.dev
target: dev
command: ["run-web"]
env_file:
- .env
ports:
- 3000:3000
- 24678:24678
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
depends_on:
server:
condition: service_started
redis:
container_name: immichtemplate_redis
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
database:
container_name: immichtemplate_postgres
image: pgvector/pgvector:pg18
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
- ${APP_DATA_LOCATION}/postgres:/var/lib/postgresql
ports:
- 5432:5432
shm_size: 128mb
healthcheck:
test: pg_isready -d ${DB_DATABASE_NAME} -U ${DB_USERNAME}
interval: 5s
timeout: 5s
retries: 10
volumes:
pnpm-store:
server-node_modules:
web-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:
+34
View File
@@ -0,0 +1,34 @@
name: app
services:
database:
container_name: app_postgres
image: pgvector/pgvector:pg18
environment:
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_DATABASE_NAME:-app}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- ./dev-data/postgres:/var/lib/postgresql/data
ports:
- '5435:5432'
shm_size: 128mb
restart: always
healthcheck:
test: pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE_NAME:-app}
interval: 10s
timeout: 5s
retries: 5
redis:
container_name: app_redis
image: docker.io/valkey/valkey:9
ports:
- '6379:6379'
healthcheck:
test: redis-cli ping || exit 1
interval: 10s
timeout: 5s
retries: 5
restart: always
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
cd /usr/src/app
echo "Installing dependencies..."
pnpm install
exec "$@"
+11
View File
@@ -0,0 +1,11 @@
APP_DATA_LOCATION=./app-data
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=postgres
# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immichtemplate
+5
View File
@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}
+2410
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "i18n",
"version": "0.1.0",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1"
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
function typescript {
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas server-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm --filter @server/sdk install --frozen-lockfile
pnpm --filter @server/sdk build
}
# Generate OpenAPI spec from server
(
cd ..
pnpm --filter immich build
pnpm --filter immich sync:open-api
cp server/server-openapi-specs.json open-api/server-openapi-specs.json
)
typescript
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.8.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
@@ -15,6 +15,8 @@
import 'dart:io';
import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:immich_mobile/utils/openapi_patching.dart';
import 'package:http/http.dart';
import 'package:intl/intl.dart';
import 'package:meta/meta.dart';
+21
View File
@@ -0,0 +1,21 @@
@@ -143,19 +143,19 @@
);
}
- Future<dynamic> deserializeAsync(String value, String targetType, {bool growable = false,}) async =>
+ Future<dynamic> deserializeAsync(String value, String targetType, {bool growable = false,}) =>
// ignore: deprecated_member_use_from_same_package
deserialize(value, targetType, growable: growable);
@Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.')
- dynamic deserialize(String value, String targetType, {bool growable = false,}) {
+ Future<dynamic> deserialize(String value, String targetType, {bool growable = false,}) async {
// Remove all spaces. Necessary for regular expressions as well.
targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments
// If the expected target type is String, nothing to do...
return targetType == 'String'
? value
- : fromJson(json.decode(value), targetType, growable: growable);
+ : fromJson(await compute((String j) => json.decode(j), value), targetType, growable: growable);
}
@@ -0,0 +1,9 @@
# Include code from immich_mobile
@@ -13,5 +13,5 @@
http: '>=0.13.0 <2.0.0'
intl: any
meta: '>=1.1.8 <2.0.0'
-dev_dependencies:
- test: '>=1.21.6 <1.22.0'
+ futo_home:
+ path: ../
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
{{>header}}
{{>part_of}}
{{#operations}}
class {{{classname}}} {
{{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
{{#operation}}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
///
/// Note: This method returns the HTTP [Response].
{{/notes}}
{{^notes}}
{{#summary}}
///
/// Note: This method returns the HTTP [Response].
{{/summary}}
{{^summary}}
/// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response].
{{/summary}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<Response> {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
// ignore: prefer_const_declarations
final apiPath = r'{{{path}}}'{{#pathParams}}
.replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}};
// ignore: prefer_final_locals
Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}};
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
{{#hasQueryParams}}
{{#queryParams}}
{{^required}}
if ({{{paramName}}} != null) {
{{/required}}
queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}}));
{{^required}}
}
{{/required}}
{{/queryParams}}
{{/hasQueryParams}}
{{#hasHeaderParams}}
{{#headerParams}}
{{#required}}
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
{{/required}}
{{^required}}
if ({{{paramName}}} != null) {
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/required}}
{{/headerParams}}
{{/hasHeaderParams}}
const contentTypes = <String>[{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}];
{{#isMultipart}}
bool hasFields = false;
final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath));
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{#isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field;
mp.files.add({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
if (hasFields) {
postBody = mp;
}
{{/isMultipart}}
{{^isMultipart}}
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
{{/isMultipart}}
return apiClient.invokeAPI(
apiPath,
'{{{httpMethod}}}',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}});
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
{{#returnType}}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
{{#native_serialization}}
{{#isArray}}
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List)
.cast<{{{returnBaseType}}}>()
.{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}};
{{/isArray}}
{{^isArray}}
{{#isMap}}
return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),);
{{/isMap}}
{{^isMap}}
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}};
{{/isMap}}{{/isArray}}{{/native_serialization}}
}
return null;
{{/returnType}}
}
{{/operation}}
}
{{/operations}}
@@ -0,0 +1,29 @@
--- api.mustache 2025-01-22 05:50:25
+++ api.mustache.modified 2025-01-22 05:52:23
@@ -51,7 +51,7 @@
{{/allParams}}
Future<Response> {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
// ignore: prefer_const_declarations
- final path = r'{{{path}}}'{{#pathParams}}
+ final apiPath = r'{{{path}}}'{{#pathParams}}
.replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}};
// ignore: prefer_final_locals
@@ -90,7 +90,7 @@
{{#isMultipart}}
bool hasFields = false;
- final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path));
+ final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(apiPath));
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
@@ -121,7 +121,7 @@
{{/isMultipart}}
return apiClient.invokeAPI(
- path,
+ apiPath,
'{{{httpMethod}}}',
queryParams,
postBody,
@@ -0,0 +1,296 @@
class {{{classname}}} {
{{>dart_constructor}}
{{#vars}}
{{#description}}
/// {{{.}}}
{{/description}}
{{^isEnum}}
{{#minimum}}
{{#description}}
///
{{/description}}
/// Minimum value: {{{.}}}
{{/minimum}}
{{#maximum}}
{{#description}}
{{^minimum}}
///
{{/minimum}}
{{/description}}
/// Maximum value: {{{.}}}
{{/maximum}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{/isEnum}}
{{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
{{/vars}}
@override
bool operator ==(Object other) => identical(this, other) || other is {{{classname}}} &&
{{#vars}}
{{#isMap}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isMap}}{{^isMap}}{{#isArray}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isArray}}{{^isArray}}other.{{{name}}} == {{{name}}}{{/isArray}}{{/isMap}}{{^-last}} &&{{/-last}}{{#-last}};{{/-last}}
{{/vars}}
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
{{#vars}}
({{#isNullable}}{{{name}}} == null ? 0 : {{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}{{{name}}} == null ? 0 : {{/defaultValue}}{{/required}}{{/isNullable}}{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.hashCode){{^-last}} +{{/-last}}{{#-last}};{{/-last}}
{{/vars}}
@override
String toString() => '{{{classname}}}[{{#vars}}{{{name}}}=${{{name}}}{{^-last}}, {{/-last}}{{/vars}}]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
{{#vars}}
{{#isNullable}}
if (this.{{{name}}} != null) {
{{/isNullable}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
if (this.{{{name}}} != null) {
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{#isDateTime}}
{{#pattern}}
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String();
{{/pattern}}
{{^pattern}}
json[r'{{{baseName}}}'] = this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String();
{{/pattern}}
{{/isDateTime}}
{{#isDate}}
{{#pattern}}
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc());
{{/pattern}}
{{^pattern}}
json[r'{{{baseName}}}'] = _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc());
{{/pattern}}
{{/isDate}}
{{^isDateTime}}
{{^isDate}}
json[r'{{{baseName}}}'] = this.{{{name}}}{{#isArray}}{{#uniqueItems}}{{#isNullable}}!{{/isNullable}}.toList(growable: false){{/uniqueItems}}{{/isArray}};
{{/isDate}}
{{/isDateTime}}
{{#isNullable}}
} else {
// json[r'{{{baseName}}}'] = null;
}
{{/isNullable}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
} else {
// json[r'{{{baseName}}}'] = null;
}
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{/vars}}
return json;
}
/// Returns a new [{{{classname}}}] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static {{{classname}}}? fromJson(dynamic value) {
upgradeDto(value, "{{{classname}}}");
if (value is Map) {
final json = value.cast<String, dynamic>();
return {{{classname}}}(
{{#vars}}
{{#isDateTime}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDateTime}}
{{#isDate}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDate}}
{{^isDateTime}}
{{^isDate}}
{{#complexType}}
{{#isArray}}
{{#items.isArray}}
{{{name}}}: json[r'{{{baseName}}}'] is List
? (json[r'{{{baseName}}}'] as List).map((e) =>
{{#items.complexType}}
{{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}}
{{/items.complexType}}
{{^items.complexType}}
e == null ? {{#items.isNullable}}null{{/items.isNullable}}{{^items.isNullable}}const <{{items.items.dataType}}>[]{{/items.isNullable}} : (e as List).cast<{{items.items.dataType}}>()
{{/items.complexType}}
).toList()
: {{#isNullable}}null{{/isNullable}}{{^isNullable}}const []{{/isNullable}},
{{/items.isArray}}
{{^items.isArray}}
{{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}},
{{/items.isArray}}
{{/isArray}}
{{^isArray}}
{{#isMap}}
{{#items.isArray}}
{{{name}}}: json[r'{{{baseName}}}'] == null
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
{{#items.complexType}}
: {{items.complexType}}.mapListFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
: mapCastOfType<String, List>(json, r'{{{baseName}}}'),
{{/items.complexType}}
{{/items.isArray}}
{{^items.isArray}}
{{#items.isMap}}
{{#items.complexType}}
{{{name}}}: {{items.complexType}}.mapFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
{{{name}}}: mapCastOfType<String, dynamic>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/items.complexType}}
{{/items.isMap}}
{{^items.isMap}}
{{#items.complexType}}
{{{name}}}: {{{items.complexType}}}.mapFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
{{{name}}}: mapCastOfType<String, {{items.dataType}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/items.complexType}}
{{/items.isMap}}
{{/items.isArray}}
{{/isMap}}
{{^isMap}}
{{#isBinary}}
{{{name}}}: null, // No support for decoding binary content from JSON
{{/isBinary}}
{{^isBinary}}
{{{name}}}: {{{complexType}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isBinary}}
{{/isMap}}
{{/isArray}}
{{/complexType}}
{{^complexType}}
{{#isArray}}
{{#isEnum}}
{{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}},
{{/isEnum}}
{{^isEnum}}
{{{name}}}: json[r'{{{baseName}}}'] is Iterable
? (json[r'{{{baseName}}}'] as Iterable).cast<{{{items.datatype}}}>().{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}}
: {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}},
{{/isEnum}}
{{/isArray}}
{{^isArray}}
{{#isMap}}
{{{name}}}: mapCastOfType<String, {{{items.datatype}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isMap}}
{{^isMap}}
{{#isNumber}}
{{{name}}}: {{#isNullable}}json[r'{{{baseName}}}'] == null
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
: {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'),
{{/isNumber}}
{{#isDouble}}
{{{name}}}: (mapValueOfType<num>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}){{#isNullable}}?{{/isNullable}}.toDouble(),
{{/isDouble}}
{{^isDouble}}
{{^isNumber}}
{{^isEnum}}
{{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{#isEnum}}
{{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{/isNumber}}
{{/isDouble}}
{{/isMap}}
{{/isArray}}
{{/complexType}}
{{/isDate}}
{{/isDateTime}}
{{/vars}}
);
}
return null;
}
static List<{{{classname}}}> listFromJson(dynamic json, {bool growable = false,}) {
final result = <{{{classname}}}>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = {{{classname}}}.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, {{{classname}}}> mapFromJson(dynamic json) {
final map = <String, {{{classname}}}>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = {{{classname}}}.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of {{{classname}}}-objects as value to a dart map
static Map<String, List<{{{classname}}}>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<{{{classname}}}>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = {{{classname}}}.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
{{#vars}}
{{#required}}
'{{{baseName}}}',
{{/required}}
{{/vars}}
};
}
{{#vars}}
{{^isModel}}
{{#isEnum}}
{{^isContainer}}
{{>serialization/native/native_enum_inline}}
{{/isContainer}}
{{#isContainer}}
{{#mostInnerItems}}
{{>serialization/native/native_enum_inline}}
{{/mostInnerItems}}
{{/isContainer}}
{{/isEnum}}
{{/isModel}}
{{/vars}}
@@ -0,0 +1,60 @@
--- native_class.mustache 2025-07-01 08:29:23.968133163 +0800
+++ native_class_temp.mustache 2025-07-01 08:29:44.225850583 +0800
@@ -91,14 +91,14 @@
{{/isDateTime}}
{{#isNullable}}
} else {
- json[r'{{{baseName}}}'] = null;
+ // json[r'{{{baseName}}}'] = null;
}
{{/isNullable}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
} else {
- json[r'{{{baseName}}}'] = null;
+ // json[r'{{{baseName}}}'] = null;
}
{{/defaultValue}}
{{/required}}
@@ -111,20 +111,10 @@
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static {{{classname}}}? fromJson(dynamic value) {
+ upgradeDto(value, "{{{classname}}}");
if (value is Map) {
final json = value.cast<String, dynamic>();
- // Ensure that the map contains the required keys.
- // Note 1: the values aren't checked for validity beyond being non-null.
- // Note 2: this code is stripped in release mode!
- assert(() {
- requiredKeys.forEach((key) {
- assert(json.containsKey(key), 'Required key "{{{classname}}}[$key]" is missing from JSON.');
- assert(json[key] != null, 'Required key "{{{classname}}}[$key]" has a null value in JSON.');
- });
- return true;
- }());
-
return {{{classname}}}(
{{#vars}}
{{#isDateTime}}
@@ -215,6 +205,10 @@
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
: {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'),
{{/isNumber}}
+ {{#isDouble}}
+ {{{name}}}: (mapValueOfType<num>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}){{#isNullable}}?{{/isNullable}}.toDouble(),
+ {{/isDouble}}
+ {{^isDouble}}
{{^isNumber}}
{{^isEnum}}
{{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
@@ -223,6 +217,7 @@
{{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{/isNumber}}
+ {{/isDouble}}
{{/isMap}}
{{/isArray}}
{{/complexType}}
@@ -0,0 +1,13 @@
diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache
index 9a7b1439b..9f40d5b0b 100644
--- a/open-api/templates/mobile/serialization/native/native_class.mustache
+++ b/open-api/templates/mobile/serialization/native/native_class.mustache
@@ -32,7 +32,7 @@ class {{{classname}}} {
{{/required}}
{{/isNullable}}
{{/isEnum}}
- {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
+ {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
{{/vars}}
@override
+3
View File
@@ -0,0 +1,3 @@
package-lock.json
tsconfig.json
src/
+1
View File
@@ -0,0 +1 @@
24.13.0
+26
View File
@@ -0,0 +1,26 @@
# @server/sdk
A TypeScript SDK for interfacing with the [Immich](https://immich.app/) API.
## Install
```bash
npm i --save @server/sdk
```
## Usage
For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli).
```typescript
import { getAllAlbums, getMyUser, init } from "@server/sdk";
const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY
init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY });
const user = await getMyUser();
const albums = await getAllAlbums({});
console.log({ user, albums });
```
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@server/sdk",
"version": "0.1.0",
"description": "Auto-generated TypeScript SDK for the Server API",
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "tsc"
},
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.9",
"typescript": "^5.3.3"
},
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.13.0"
}
}
+545
View File
@@ -0,0 +1,545 @@
/**
* App API
* 0.1.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
import * as Oazapfts from "@oazapfts/runtime";
import * as QS from "@oazapfts/runtime/query";
export const defaults: Oazapfts.Defaults<Oazapfts.CustomHeaders> = {
headers: {},
baseUrl: "/api"
};
const oazapfts = Oazapfts.runtime(defaults);
export const servers = {
server1: "/api"
};
export type ApiKeyCreateDto = {
/** API key name */
name?: string;
/** List of permissions */
permissions: Permission[];
};
export type ApiKeyResponseDto = {
/** API key ID */
id: string;
/** API key name */
name: string;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** List of permissions */
permissions: Permission[];
};
export type ApiKeyCreateResponseDto = {
/** API key secret (only shown once) */
secret: string;
apiKey: ApiKeyResponseDto;
};
export type ApiKeyUpdateDto = {
/** API key name */
name?: string;
/** List of permissions */
permissions?: Permission[];
};
export type LoginCredentialDto = {
/** User email */
email: string;
/** User password */
password: string;
};
export type LoginResponseDto = {
/** Access token */
accessToken: string;
/** User ID */
userId: string;
/** User email */
userEmail: string;
/** User name */
name: string;
/** Is admin user */
isAdmin: boolean;
/** Should change password */
shouldChangePassword: boolean;
};
export type SignUpDto = {
/** User email */
email: string;
/** User password */
password: string;
/** User name */
name: string;
};
export type UserAdminResponseDto = {
/** User ID */
id: string;
/** User name */
name: string;
/** User email */
email: string;
/** Require password change on next login */
shouldChangePassword: boolean;
/** Is admin user */
isAdmin: boolean;
/** Creation date */
createdAt: string;
/** Deletion date */
deletedAt: string | null;
/** Last update date */
updatedAt: string;
/** User status */
status: UserStatus;
};
export type ValidateAccessTokenResponseDto = {
/** Authentication status */
authStatus: boolean;
};
export type ChangePasswordDto = {
/** Current password */
password: string;
/** New password (min 8 characters) */
newPassword: string;
/** Invalidate all other sessions */
invalidateSessions?: boolean;
};
export type LogoutResponseDto = {
/** Logout successful */
successful: boolean;
/** Redirect URI */
redirectUri: string;
};
export type ServerAboutResponseDto = {
/** Server version */
version: string;
/** URL to version information */
versionUrl: string;
/** Repository name */
repository?: string;
/** Repository URL */
repositoryUrl?: string;
/** Source reference (branch/tag) */
sourceRef?: string;
/** Source commit hash */
sourceCommit?: string;
/** Source URL */
sourceUrl?: string;
/** Build identifier */
build?: string;
/** Build URL */
buildUrl?: string;
/** Node.js version */
nodejs?: string;
};
export type ServerPingResponse = {};
export type ServerPingResponseRead = {
res: string;
};
export type ServerVersionResponseDto = {
/** Major version number */
major: number;
/** Minor version number */
minor: number;
/** Patch version number */
patch: number;
};
export type ServerFeaturesDto = {
/** Whether password login is enabled */
passwordLogin: boolean;
};
export type ServerConfigDto = {
/** Login page message */
loginPageMessage: string;
/** Delay in days before deleted users are permanently removed */
userDeleteDelay: number;
/** Whether the server has been initialized */
isInitialized: boolean;
};
export type SessionCreateDto = {
/** Session duration in seconds */
duration?: number;
/** Device type */
deviceType?: string;
/** Device OS */
deviceOS?: string;
};
export type SessionCreateResponseDto = {
/** Session ID */
id: string;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** Expiration date */
expiresAt?: string;
/** Is current session */
current: boolean;
/** Device type */
deviceType: string;
/** Device OS */
deviceOS: string;
/** App version */
appVersion: string | null;
/** Is pending sync reset */
isPendingSyncReset: boolean;
/** Session token */
token: string;
};
export type SessionResponseDto = {
/** Session ID */
id: string;
/** Creation date */
createdAt: string;
/** Last update date */
updatedAt: string;
/** Expiration date */
expiresAt?: string;
/** Is current session */
current: boolean;
/** Device type */
deviceType: string;
/** Device OS */
deviceOS: string;
/** App version */
appVersion: string | null;
/** Is pending sync reset */
isPendingSyncReset: boolean;
};
export type UserResponseDto = {
/** User ID */
id: string;
/** User name */
name: string;
/** User email */
email: string;
};
export type UserUpdateMeDto = {
/** User email */
email?: string;
/** User name */
name?: string;
};
/**
* Create an API key
*/
export function createApiKey({ apiKeyCreateDto }: {
apiKeyCreateDto: ApiKeyCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: ApiKeyCreateResponseDto;
}>("/api-keys", oazapfts.json({
...opts,
method: "POST",
body: apiKeyCreateDto
})));
}
/**
* List all API keys
*/
export function getApiKeys(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ApiKeyResponseDto[];
}>("/api-keys", {
...opts
}));
}
/**
* Retrieve the current API key
*/
export function getMyApiKey(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ApiKeyResponseDto;
}>("/api-keys/me", {
...opts
}));
}
/**
* Retrieve an API key
*/
export function getApiKey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ApiKeyResponseDto;
}>(`/api-keys/${encodeURIComponent(id)}`, {
...opts
}));
}
/**
* Update an API key
*/
export function updateApiKey({ id, apiKeyUpdateDto }: {
id: string;
apiKeyUpdateDto: ApiKeyUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ApiKeyResponseDto;
}>(`/api-keys/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: apiKeyUpdateDto
})));
}
/**
* Delete an API key
*/
export function deleteApiKey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/api-keys/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Login
*/
export function login({ loginCredentialDto }: {
loginCredentialDto: LoginCredentialDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: LoginResponseDto;
}>("/auth/login", oazapfts.json({
...opts,
method: "POST",
body: loginCredentialDto
})));
}
/**
* Register admin
*/
export function signUpAdmin({ signUpDto }: {
signUpDto: SignUpDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserAdminResponseDto;
}>("/auth/admin-sign-up", oazapfts.json({
...opts,
method: "POST",
body: signUpDto
})));
}
/**
* Validate access token
*/
export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ValidateAccessTokenResponseDto;
}>("/auth/validateToken", {
...opts,
method: "POST"
}));
}
/**
* Change password
*/
export function changePassword({ changePasswordDto }: {
changePasswordDto: ChangePasswordDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/auth/change-password", oazapfts.json({
...opts,
method: "POST",
body: changePasswordDto
})));
}
/**
* Logout
*/
export function logout(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LogoutResponseDto;
}>("/auth/logout", {
...opts,
method: "POST"
}));
}
/**
* Get server information
*/
export function getAboutInfo(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerAboutResponseDto;
}>("/server/about", {
...opts
}));
}
/**
* Ping
*/
export function pingServer(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerPingResponseRead;
}>("/server/ping", {
...opts
}));
}
/**
* Get server version
*/
export function getServerVersion(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerVersionResponseDto;
}>("/server/version", {
...opts
}));
}
/**
* Get features
*/
export function getServerFeatures(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerFeaturesDto;
}>("/server/features", {
...opts
}));
}
/**
* Get config
*/
export function getServerConfig(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerConfigDto;
}>("/server/config", {
...opts
}));
}
/**
* Create a session
*/
export function createSession({ sessionCreateDto }: {
sessionCreateDto: SessionCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SessionCreateResponseDto;
}>("/sessions", oazapfts.json({
...opts,
method: "POST",
body: sessionCreateDto
})));
}
/**
* Retrieve sessions
*/
export function getSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SessionResponseDto[];
}>("/sessions", {
...opts
}));
}
/**
* Delete all sessions
*/
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts,
method: "DELETE"
}));
}
/**
* Delete a session
*/
export function deleteSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Get all users
*/
export function searchUsers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto[];
}>("/users", {
...opts
}));
}
/**
* Get current user
*/
export function getMyUser(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/users/me", {
...opts
}));
}
/**
* Update current user
*/
export function updateMyUser({ userUpdateMeDto }: {
userUpdateMeDto: UserUpdateMeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/users/me", oazapfts.json({
...opts,
method: "PUT",
body: userUpdateMeDto
})));
}
/**
* Retrieve a user
*/
export function getUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>(`/users/${encodeURIComponent(id)}`, {
...opts
}));
}
export enum Permission {
All = "all",
ApiKeyCreate = "apiKey.create",
ApiKeyRead = "apiKey.read",
ApiKeyUpdate = "apiKey.update",
ApiKeyDelete = "apiKey.delete",
AuthChangePassword = "auth.changePassword",
AuthDeviceDelete = "authDevice.delete",
SessionCreate = "session.create",
SessionRead = "session.read",
SessionUpdate = "session.update",
SessionDelete = "session.delete",
SystemMetadataRead = "systemMetadata.read",
SystemMetadataUpdate = "systemMetadata.update",
UserRead = "user.read",
UserUpdate = "user.update",
ServerAbout = "server.about",
AdminUserCreate = "adminUser.create",
AdminUserRead = "adminUser.read",
AdminUserUpdate = "adminUser.update",
AdminUserDelete = "adminUser.delete"
}
export enum UserStatus {
Active = "active",
Removing = "removing",
Deleted = "deleted"
}
@@ -0,0 +1,15 @@
import { HttpError } from '@oazapfts/runtime';
export interface ApiExceptionResponse {
message: string;
error?: string;
statusCode: number;
}
export interface ApiHttpError extends HttpError {
data: ApiExceptionResponse;
}
export function isHttpError(error: unknown): error is ApiHttpError {
return error instanceof HttpError;
}
+49
View File
@@ -0,0 +1,49 @@
import { defaults } from './fetch-client.js';
export * from './fetch-client.js';
export * from './fetch-errors.js';
export interface InitOptions {
baseUrl: string;
apiKey: string;
headers?: Record<string, string>;
}
export const init = ({ baseUrl, apiKey, headers }: InitOptions) => {
setBaseUrl(baseUrl);
setApiKey(apiKey);
if (headers) {
setHeaders(headers);
}
};
export const getBaseUrl = () => defaults.baseUrl;
export const setBaseUrl = (baseUrl: string) => {
defaults.baseUrl = baseUrl;
};
export const setApiKey = (apiKey: string) => {
defaults.headers = defaults.headers || {};
defaults.headers['x-api-key'] = apiKey;
};
export const setHeader = (key: string, value: string) => {
assertNoApiKey(key);
defaults.headers = defaults.headers || {};
defaults.headers[key] = value;
};
export const setHeaders = (headers: Record<string, string>) => {
defaults.headers = defaults.headers || {};
for (const [key, value] of Object.entries(headers)) {
assertNoApiKey(key);
defaults.headers[key] = value;
}
};
const assertNoApiKey = (headerKey: string) => {
if (headerKey.toLowerCase() === 'x-api-key') {
throw new Error('The API key header can only be set using setApiKey().');
}
};
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "build",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["esnext", "dom"]
},
"include": ["src/**/*.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "core-monorepo",
"version": "0.1.0",
"description": "NestJS + SvelteKit + Flutter monorepo template",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
"engines": {
"pnpm": ">=10.0.0"
}
}
+11691
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
packages:
- i18n
- open-api/typescript-sdk
- server
- web
ignoredBuiltDependencies:
- "@nestjs/core"
- "@swc/core"
- esbuild
onlyBuiltDependencies:
- "@tailwindcss/oxide"
- bcrypt
packageExtensions:
nestjs-kysely:
dependencies:
tslib: "*"
nestjs-otel:
dependencies:
tslib: "*"
bcrypt:
dependencies:
node-addon-api: "*"
node-gyp: "*"
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
shamefullyHoist: false
+46
View File
@@ -0,0 +1,46 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
www/
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.env
dist/
upload/
tmp/
core
**/node_modules/**
+5
View File
@@ -0,0 +1,5 @@
src
tsconfig*
eslint*
pnpm*
coverage
+1
View File
@@ -0,0 +1 @@
24.13.0
+18
View File
@@ -0,0 +1,18 @@
.DS_Store
node_modules
/build
/package
.env
.env.*
!.env.example
*.md
*.json
coverage
dist
**/migrations/**
db.d.ts
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"plugins": ["prettier-plugin-organize-imports"]
}
+112
View File
@@ -0,0 +1,112 @@
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
PNPM_HOME=/buildcache/pnpm-store \
PATH="/buildcache/pnpm-store:$PATH"
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
pnpm config set store-dir "$PNPM_HOME"
FROM builder AS server
WORKDIR /usr/src/app
COPY ./server ./server/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web
WORKDIR /usr/src/app
COPY ./web ./web/
COPY ./i18n ./i18n/
COPY ./open-api ./open-api/
RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @server/sdk --filter immich-web --frozen-lockfile --force install && \
pnpm --filter @server/sdk --filter immich-web build
FROM builder AS cli
COPY ./cli ./cli/
COPY ./open-api ./open-api/
RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @server/sdk --filter @immich/cli --frozen-lockfile install && \
pnpm --filter @server/sdk --filter @immich/cli build && \
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --cd plugins
COPY ./plugins ./plugins/
# Build plugins
RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
WORKDIR /usr/src/app
ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/server/bin"
ARG BUILD_ID
ARG BUILD_IMAGE
ARG BUILD_SOURCE_REF
ARG BUILD_SOURCE_COMMIT
ENV IMMICH_BUILD=${BUILD_ID}
ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID}
ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE}
ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-server
ENV IMMICH_REPOSITORY=immich-app/immich
ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich
ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
VOLUME /data
EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
CMD ["start.sh"]
HEALTHCHECK CMD immich-healthcheck
+82
View File
@@ -0,0 +1,82 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/
COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/
COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/
COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/
WORKDIR /tmp/create-dep-cache
RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache
WORKDIR /usr/src/app
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
vim nano curl \
-y --no-install-recommends --fix-missing
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
mkdir -p /workspaces/immich
RUN chown node:node -R /workspaces
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich
FROM dev-container-server AS dev-container-mobile
USER root
# Enable multiarch for arm64 if necessary
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg \
qemu-user-static \
libc6:amd64 \
libstdc++6:amd64 \
libgcc1:amd64; \
else \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg; \
fi
# Flutter SDK
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
ENV FLUTTER_CHANNEL="stable"
ENV FLUTTER_VERSION="3.35.7"
ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
# Flutter SDK
RUN mkdir -p ${FLUTTER_HOME} \
&& curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
&& tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
&& rm flutter.tar.xz \
&& chown -R node ${FLUTTER_HOME} \
&& git config --global --add safe.directory ${FLUTTER_HOME}
RUN wget -qO- https://dcm.dev/pgp-key.public | gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
&& echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \
&& apt-get update \
&& apt-get install dcm -y
RUN dart --disable-analytics
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
LOG_LEVEL="${IMMICH_LOG_LEVEL:='info'}"
logDebug() {
if [ "$LOG_LEVEL" = "debug" ] || [ "$LOG_LEVEL" = "verbose" ]; then
echo "DEBUG: $1" >&2
fi
}
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
logDebug "cgroup v2 detected."
if [ -f /sys/fs/cgroup/cpu.max ]; then
read -r quota period </sys/fs/cgroup/cpu.max
if [ "$quota" = "max" ]; then
logDebug "No CPU limits set."
unset quota period
fi
else
logDebug "/sys/fs/cgroup/cpu.max not found."
fi
else
logDebug "cgroup v1 detected."
if [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ] && [ -f /sys/fs/cgroup/cpu/cpu.cfs_period_us ]; then
quota=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us)
period=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us)
if [ "$quota" = "-1" ]; then
logDebug "No CPU limits set."
unset quota period
fi
else
logDebug "/sys/fs/cgroup/cpu/cpu.cfs_quota_us or /sys/fs/cgroup/cpu/cpu.cfs_period_us not found."
fi
fi
if [ -n "${quota:-}" ] && [ -n "${period:-}" ]; then
cpus=$((quota / period))
if [ "$cpus" -eq 0 ]; then
cpus=1
fi
else
cpus=$(grep -c ^processor /proc/cpuinfo)
fi
echo "$cpus"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
if [[ "$IMMICH_ENV" == "production" ]]; then
echo "This command can only be run in development environments"
exit 1
fi
cd /usr/src/app || exit
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Quiet mode suppresses informational output (enabled for immich-admin)
QUIET=false
if [ "$1" = "immich-admin" ]; then
QUIET=true
fi
# Helper function that only logs when not in quiet mode
log_message() {
if [ "$QUIET" = "false" ]; then
echo "$@"
fi
}
log_message "Initializing Immich $IMMICH_SOURCE_REF"
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
# if [ -f "$lib_path" ]; then
# export LD_PRELOAD="$lib_path"
# else
# echo "skipping libmimalloc - path not found $lib_path"
# fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
read_file_and_export() {
fname="${!1}"
if [[ -z $fname ]] && [[ -e "$CREDENTIALS_DIRECTORY/$2" ]]; then
fname="${CREDENTIALS_DIRECTORY}/$2"
fi
if [[ -n $fname ]]; then
content="$(< "$fname")"
export "$2"="${content}"
unset "$1"
fi
}
read_file_and_export "DB_URL_FILE" "DB_URL"
read_file_and_export "DB_HOSTNAME_FILE" "DB_HOSTNAME"
read_file_and_export "DB_DATABASE_NAME_FILE" "DB_DATABASE_NAME"
read_file_and_export "DB_USERNAME_FILE" "DB_USERNAME"
read_file_and_export "DB_PASSWORD_FILE" "DB_PASSWORD"
read_file_and_export "REDIS_PASSWORD_FILE" "REDIS_PASSWORD"
if CPU_CORES="${CPU_CORES:=$(get-cpus.sh 2>/dev/null)}"; then
log_message "Detected CPU Cores: $CPU_CORES"
if [ "$CPU_CORES" -gt 4 ]; then
export UV_THREADPOOL_SIZE=$CPU_CORES
fi
else
log_message "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE"
fi
if [ -f "${SERVER_HOME}/dist/main.js" ]; then
if [ "$QUIET" = "true" ]; then
exec node --no-warnings "${SERVER_HOME}/dist/main.js" "$@"
else
exec node "${SERVER_HOME}/dist/main.js" "$@"
fi
else
echo "Error: ${SERVER_HOME}/dist/main.js not found"
if [ "$IMMICH_ENV" = "development" ]; then
echo "You may need to build the server first."
fi
exit 1
fi
+80
View File
@@ -0,0 +1,80 @@
import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
js.configs.recommended,
typescriptEslint.configs.recommended,
{
ignores: ['eslint.config.mjs'],
},
{
languageOptions: {
globals: {
...globals.node,
},
parser: typescriptEslint.parser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'unicorn/import-style': 'off',
'unicorn/prefer-structured-clone': 'off',
'unicorn/no-for-loop': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
curly: 2,
'prettier/prettier': 0,
'object-shorthand': ['error', 'always'],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['.*'],
message: 'Relative imports are not allowed.',
},
],
},
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
]);
+66
View File
@@ -0,0 +1,66 @@
[tasks.install]
run = "pnpm install --filter immich --frozen-lockfile"
[tasks.build]
env._.path = "./node_modules/.bin"
run = "nest build"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vitest --config test/vitest.config.mjs"
[tasks."test-medium"]
env._.path = "./node_modules/.bin"
run = "vitest --config test/vitest.config.medium.mjs"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
[tasks."lint-fix"]
run = { task = "lint --fix" }
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.sql]
run = "node ./dist/bin/sync-open-api.js"
[tasks."open-api"]
run = "node ./dist/bin/sync-open-api.js"
[tasks.migrations]
run = "node ./dist/bin/migrations.js"
description = "Run database migration commands (create, generate, run, debug, or query)"
[tasks."schema-drop"]
run = { task = "migrations query 'DROP schema public cascade; CREATE schema public;'" }
[tasks."schema-reset"]
run = [
{ task = ":schema-drop" },
{ task = "migrations run" },
]
[tasks."email-dev"]
env._.path = "./node_modules/.bin"
run = "email dev -p 3050 --dir src/emails"
[tasks.checklist]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test-medium --run" },
{ task = ":test --run" },
]
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"webpack": false,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true
}
}
]
}
}
+133
View File
@@ -0,0 +1,133 @@
{
"name": "immich",
"version": "0.1.0",
"description": "",
"author": "",
"private": true,
"license": "GNU Affero General Public License version 3",
"scripts": {
"build": "nest build",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"start": "npm run start:dev",
"nest": "nest",
"start:dev": "nest start --watch --",
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"check": "tsc --noEmit",
"check:code": "npm run format && npm run lint && npm run check",
"check:all": "npm run check:code && npm run test:cov",
"test": "vitest --config test/vitest.config.mjs",
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"migrations:debug": "node ./dist/bin/migrations.js debug",
"migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create",
"migrations:run": "node ./dist/bin/migrations.js run",
"migrations:revert": "node ./dist/bin/migrations.js revert",
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "npm run schema:drop && npm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.0.2",
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.210.0",
"@opentelemetry/instrumentation-http": "^0.210.0",
"@opentelemetry/instrumentation-ioredis": "^0.58.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.56.0",
"@opentelemetry/instrumentation-pg": "^0.62.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@socket.io/redis-adapter": "^8.3.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.5",
"express": "^5.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"jose": "^5.10.0",
"kysely": "0.28.2",
"lodash": "^4.17.21",
"kysely-postgres-js": "^3.0.0",
"luxon": "^3.4.2",
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
"postgres": "3.4.8",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.6.2",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4",
"@swc/core": "^1.4.14",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.17.16",
"@types/luxon": "^3.6.2",
"@types/node": "^24.10.9",
"@types/pg": "^8.11.0",
"@types/semver": "^7.5.8",
"@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.36",
"@types/validator": "^13.15.2",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"node-gyp": "^12.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sql-formatter": "^15.0.0",
"supertest": "^7.1.0",
"testcontainers": "^11.0.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0"
},
"volta": {
"node": "24.13.0"
},
"overrides": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];
const apiMiddleware = [...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const commonImports = [
ClsModule.forRoot(cls.config),
KyselyModule.forRoot(getKyselyConfig(database.config)),
OpenTelemetryModule.forRoot(otel),
];
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
private authService: AuthService,
private eventRepository: EventRepository,
private telemetryRepository: TelemetryRepository,
private websocketRepository: WebsocketRepository,
) {
logger.setAppName(this.worker);
}
async onModuleInit() {
this.telemetryRepository.setup({ repositories });
this.websocketRepository.setAuthFn(async (client) =>
this.authService.authenticate({
headers: client.request.headers,
queryParams: {},
metadata: { adminRoute: false, uri: '/api/socket.io' },
}),
);
this.eventRepository.setup({ services });
await this.eventRepository.emit('app.bootstrap');
}
async onModuleDestroy() {
await this.eventRepository.emit('app.shutdown');
await teardownTelemetry();
}
}
@Module({
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
+12
View File
@@ -0,0 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { ApiModule } from 'src/app.module';
import { useSwagger } from 'src/utils/misc';
async function bootstrap() {
const app = await NestFactory.create(ApiModule, { logger: false });
useSwagger(app, { write: true });
await app.close();
console.log('OpenAPI spec written to server-openapi-specs.json');
}
void bootstrap();
+45
View File
@@ -0,0 +1,45 @@
import { LogLevel, QueueName } from 'src/enum';
export type SystemConfig = {
job: Record<QueueName, { concurrency: number }>;
logging: {
enabled: boolean;
level: LogLevel;
};
passwordLogin: {
enabled: boolean;
};
server: {
externalDomain: string;
loginPageMessage: string;
};
theme: {
customCss: string;
};
user: {
deleteDelay: number;
};
};
export const defaults = Object.freeze<SystemConfig>({
job: {
[QueueName.BackgroundTask]: { concurrency: 5 },
},
logging: {
enabled: true,
level: LogLevel.Log,
},
passwordLogin: {
enabled: true,
},
server: {
externalDomain: '',
loginPageMessage: '',
},
theme: {
customCss: '',
},
user: {
deleteDelay: 7,
},
});
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, DatabaseExtension } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
vector: 'pgvector',
} as const;
export const VECTOR_EXTENSIONS = [DatabaseExtension.Vector] as const;
export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker';
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const endpointTags: Record<ApiTag, string> = {
[ApiTag.ApiKeys]: 'An API key can be used to programmatically access the API.',
[ApiTag.Authentication]: 'Endpoints related to user authentication.',
[ApiTag.Server]: 'Information about the current server deployment, including version, features, and health.',
[ApiTag.Sessions]: 'A session represents an authenticated login session for a user.',
[ApiTag.Users]: 'Endpoints for viewing and updating the current user.',
};
@@ -0,0 +1,86 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ApiKeyService } from 'src/services/api-key.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.ApiKeys)
@Controller('api-keys')
export class ApiKeyController {
constructor(private service: ApiKeyService) {}
@Post()
@Authenticated({ permission: Permission.ApiKeyCreate })
@Endpoint({
summary: 'Create an API key',
description: 'Creates a new API key. It will be limited to the permissions specified.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.ApiKeyRead })
@Endpoint({
summary: 'List all API keys',
description: 'Retrieve all API keys of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(auth);
}
@Get('me')
@Authenticated({ permission: false })
@Endpoint({
summary: 'Retrieve the current API key',
description: 'Retrieve the API key that is used to access this endpoint.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead })
@Endpoint({
summary: 'Retrieve an API key',
description: 'Retrieve an API key by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.ApiKeyUpdate })
@Endpoint({
summary: 'Update an API key',
description: 'Updates the name and permissions of an API key by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete an API key',
description: 'Deletes an API key identified by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Header } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
@Controller()
export class AppController {
@ApiExcludeEndpoint()
@Get('.well-known/immich')
getImmichWellKnown() {
return { api: { endpoint: '/api' } };
}
@ApiExcludeEndpoint()
@Get('custom.css')
@Header('Content-Type', 'text/css')
getCustomCss() {
return '';
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
ChangePasswordDto,
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ApiTags(ApiTag.Authentication)
@Controller('auth')
export class AuthController {
constructor(private service: AuthService) {}
@Post('login')
@Endpoint({
summary: 'Login',
description: 'Login with username and password and receive a session token.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async login(
@Res({ passthrough: true }) res: Response,
@Body() loginCredential: LoginCredentialDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.login(loginCredential, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Password },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
}
@Post('admin-sign-up')
@Endpoint({
summary: 'Register admin',
description: 'Create the first admin user in the system.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
return this.service.adminSignUp(dto);
}
@Post('validateToken')
@Endpoint({
summary: 'Validate access token',
description: 'Validate the current authorization method is still valid.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@Authenticated({ permission: false })
@HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
@Authenticated({ permission: Permission.AuthChangePassword })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Change password',
description: 'Change the password of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto);
}
@Post('logout')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Logout',
description: 'Logout the current user and invalidate the session token.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async logout(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto,
): Promise<LogoutResponseDto> {
const authType = (request.cookies || {})[ImmichCookie.AuthType];
const body = await this.service.logout(auth, authType);
return respondWithoutCookie(res, body, [
ImmichCookie.AccessToken,
ImmichCookie.AuthType,
ImmichCookie.IsAuthenticated,
]);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { ServerController } from 'src/controllers/server.controller';
import { SessionController } from 'src/controllers/session.controller';
import { UserController } from 'src/controllers/user.controller';
export const controllers = [
ApiKeyController,
AppController,
AuthController,
ServerController,
SessionController,
UserController,
];
@@ -0,0 +1,70 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
ServerAboutResponseDto,
ServerConfigDto,
ServerFeaturesDto,
ServerPingResponse,
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerService } from 'src/services/server.service';
@ApiTags(ApiTag.Server)
@Controller('server')
export class ServerController {
constructor(private service: ServerService) {}
@Get('about')
@Authenticated({ permission: Permission.ServerAbout })
@Endpoint({
summary: 'Get server information',
description: 'Retrieve a list of information about the server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAboutInfo(): Promise<ServerAboutResponseDto> {
return this.service.getAboutInfo();
}
@Get('ping')
@Endpoint({
summary: 'Ping',
description: 'Pong',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('version')
@Endpoint({
summary: 'Get server version',
description: 'Retrieve the current server version in semantic versioning (semver) format.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();
}
@Get('features')
@Endpoint({
summary: 'Get features',
description: 'Retrieve available features supported by this server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures();
}
@Get('config')
@Endpoint({
summary: 'Get config',
description: 'Retrieve the current server configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
}
@@ -0,0 +1,61 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Sessions)
@Controller('sessions')
export class SessionController {
constructor(private service: SessionService) {}
@Post()
@Authenticated({ permission: Permission.SessionCreate })
@Endpoint({
summary: 'Create a session',
description: 'Create a session as a child to the current session. This endpoint is used for casting.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.SessionRead })
@Endpoint({
summary: 'Retrieve sessions',
description: 'Retrieve a list of sessions for the user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
return this.service.getAll(auth);
}
@Delete()
@Authenticated({ permission: Permission.SessionDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete all sessions',
description: 'Delete all sessions for the user. This will not delete the current session.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteAllSessions(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteAll(auth);
}
@Delete(':id')
@Authenticated({ permission: Permission.SessionDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a session',
description: 'Delete a specific session by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserService } from 'src/services/user.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Users)
@Controller(RouteKey.User)
export class UserController {
constructor(private service: UserService) {}
@Get()
@Authenticated({ permission: Permission.UserRead })
@Endpoint({
summary: 'Get all users',
description: 'Retrieve a list of all users on the server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(auth);
}
@Get('me')
@Authenticated({ permission: Permission.UserRead })
@Endpoint({
summary: 'Get current user',
description: 'Retrieve information about the user making the API request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getMyUser(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.getMe(auth);
}
@Put('me')
@Authenticated({ permission: Permission.UserUpdate })
@Endpoint({
summary: 'Update current user',
description: 'Update the current user making the API request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.UserRead })
@Endpoint({
summary: 'Retrieve a user',
description: 'Retrieve a specific user by their ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Permission, UserStatus } from 'src/enum';
export type AuthUser = {
id: string;
isAdmin: boolean;
name: string;
email: string;
};
export type AuthApiKey = {
id: string;
permissions: Permission[];
};
export type ApiKey = {
id: string;
name: string;
userId: string;
createdAt: Date;
updatedAt: Date;
permissions: Permission[];
};
export type User = {
id: string;
name: string;
email: string;
};
export type UserAdmin = User & {
shouldChangePassword: boolean;
isAdmin: boolean;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
status: UserStatus;
};
export type AuthSession = {
id: string;
hasElevatedPermission: boolean;
};
export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
expiresAt: Date | null;
deviceOS: string;
deviceType: string;
appVersion: string | null;
pinExpiresAt: Date | null;
isPendingSyncReset: boolean;
};
const userColumns = ['id', 'name', 'email'] as const;
export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
user: userColumns,
userAdmin: [
...userColumns,
'createdAt',
'updatedAt',
'deletedAt',
'isAdmin',
'status',
'shouldChangePassword',
],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
} as const;
+272
View File
@@ -0,0 +1,272 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` });
export const UpdateIdColumn = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
GeneratedUuidV7Column(options);
export const CreateIdColumn = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
GeneratedUuidV7Column(options);
export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true });
export const UpdatedAtTrigger = (name: string) =>
BeforeUpdateTrigger({
name,
scope: 'row',
function: updated_at,
});
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
// by a list of IDs) requires splitting the query into multiple chunks.
// We are rounding down this limit, as queries commonly include other filters and parameters.
export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;
/**
* Chunks an array or set into smaller collections of the same type and specified size.
*
* @param collection The collection to chunk.
* @param size The size of each chunk.
*/
function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>;
function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>;
function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> {
if (collection instanceof Set) {
const result = [];
let chunk = new Set<T>();
for (const element of collection) {
chunk.add(element);
if (chunk.size === size) {
result.push(chunk);
chunk = new Set<T>();
}
}
if (chunk.size > 0) {
result.push(chunk);
}
return result;
} else {
return _.chunk(collection, size);
}
}
/**
* Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
* to overcome the maximum number of parameters allowed by the database driver.
*
* @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
* @param options.flatten Whether to flatten the results. Defaults to false.
*/
export function Chunked(
options: { paramIndex?: number; chunkSize?: number; mergeFn?: (results: any) => any } = {},
): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
const parameterIndex = options.paramIndex ?? 0;
const chunkSize = options.chunkSize || DATABASE_PARAMETER_CHUNK_SIZE;
descriptor.value = async function (...arguments_: any[]) {
const argument = arguments_[parameterIndex];
// Early return if argument length is less than or equal to the chunk size.
if (
(Array.isArray(argument) && argument.length <= chunkSize) ||
(argument instanceof Set && argument.size <= chunkSize)
) {
return await originalMethod.apply(this, arguments_);
}
return Promise.all(
chunks(argument, chunkSize).map(async (chunk) => {
return await Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
...arguments_.slice(parameterIndex + 1),
]);
}),
).then((results) => (options.mergeFn ? options.mergeFn(results) : results));
};
};
}
export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator {
return Chunked({ ...options, mergeFn: _.flatten });
}
export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
return Chunked({ ...options, mergeFn: (args: Set<any>[]) => setUnion(...args) });
}
const UUID = '00000000-0000-4000-a000-000000000000';
export const DummyValue = {
UUID,
UUID_SET: new Set([UUID]),
PAGINATION: { take: 10, skip: 0 },
EMAIL: 'user@example.com',
STRING: 'abcdefghi',
NUMBER: 50,
BUFFER: Buffer.from('abcdefghi'),
DATE: new Date(),
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
BOOLEAN: true,
VECTOR: JSON.stringify(Array.from({ length: 512 }, () => 0)),
};
export const GENERATE_SQL_KEY = 'generate-sql-key';
export interface GenerateSqlQueries {
name?: string;
params: unknown[];
stream?: boolean;
}
export const Telemetry = (options: { enabled?: boolean }) =>
SetMetadata(MetadataKey.TelemetryEnabled, options?.enabled ?? true);
/** Decorator to enable versioning/tracking of generated Sql */
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
export type EventConfig = {
name: EmitEvent;
/** handle socket.io server events as well */
server?: boolean;
/** lower value has higher priority, defaults to 0 */
priority?: number;
/** register events for these workers, defaults to all workers */
workers?: ImmichWorker[];
};
export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EventConfig, config);
export type JobConfig = {
name: JobName;
queue: QueueName;
};
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JobConfig, config);
type EndpointOptions = ApiOperationOptions & { history?: HistoryBuilder };
export const Endpoint = ({ history, ...options }: EndpointOptions) => {
const decorators: MethodDecorator[] = [];
const extensions = history?.getExtensions() ?? {};
if (!extensions[ApiCustomExtension.History]) {
console.log(`Missing history for endpoint: ${options.summary}`);
}
if (history?.isDeprecated()) {
options.deprecated = true;
}
decorators.push(ApiOperation({ ...options, ...extensions }));
return applyDecorators(...decorators);
};
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
description?: string;
replacementId?: string;
};
type DeprecatedOptions = {
/** replacement operationId */
replacementId?: string;
};
type CustomExtensions = {
[ApiCustomExtension.State]?: ApiState;
[ApiCustomExtension.History]?: HistoryEntry[];
};
enum ApiState {
'Stable' = 'Stable',
'Alpha' = 'Alpha',
'Beta' = 'Beta',
'Internal' = 'Internal',
'Deprecated' = 'Deprecated',
}
export class HistoryBuilder {
private hasDeprecated = false;
private items: HistoryEntry[] = [];
added(version: string, description?: string) {
return this.push({ version, state: 'Added', description });
}
updated(version: string, description: string) {
return this.push({ version, state: 'Updated', description });
}
alpha(version: string) {
return this.push({ version, state: ApiState.Alpha });
}
beta(version: string) {
return this.push({ version, state: ApiState.Beta });
}
internal(version: string) {
return this.push({ version, state: ApiState.Internal });
}
stable(version: string) {
return this.push({ version, state: ApiState.Stable });
}
deprecated(version: string, options?: DeprecatedOptions) {
const { replacementId } = options || {};
this.hasDeprecated = true;
return this.push({ version, state: ApiState.Deprecated, replacementId });
}
isDeprecated(): boolean {
return this.hasDeprecated;
}
getExtensions() {
const extensions: CustomExtensions = {};
if (this.items.length > 0) {
extensions[ApiCustomExtension.History] = this.items;
}
for (const item of this.items.toReversed()) {
if (item.state === 'Added' || item.state === 'Updated') {
continue;
}
extensions[ApiCustomExtension.State] = item.state;
break;
}
return extensions;
}
private push(item: HistoryEntry) {
if (!item.version.startsWith('v')) {
throw new Error(`Version string must start with 'v': received '${JSON.stringify(item)}'`);
}
this.items.push(item);
return this;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsNotEmpty, IsString } from 'class-validator';
import { Permission } from 'src/enum';
import { Optional, ValidateEnum } from 'src/validation';
export class APIKeyCreateDto {
@ApiPropertyOptional({ description: 'API key name' })
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
@ArrayMinSize(1)
permissions!: Permission[];
}
export class APIKeyUpdateDto {
@ApiPropertyOptional({ description: 'API key name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateEnum({
enum: Permission,
name: 'Permission',
description: 'List of permissions',
each: true,
optional: true,
})
@ArrayMinSize(1)
permissions?: Permission[];
}
export class APIKeyResponseDto {
@ApiProperty({ description: 'API key ID' })
id!: string;
@ApiProperty({ description: 'API key name' })
name!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
permissions!: Permission[];
}
export class APIKeyCreateResponseDto {
@ApiProperty({ description: 'API key secret (only shown once)' })
secret!: string;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
apiKey!: APIKeyResponseDto;
}
+96
View File
@@ -0,0 +1,96 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie } from 'src/enum';
import { toEmail, ValidateBoolean } from 'src/validation';
export type CookieResponse = {
isSecure: boolean;
values: Array<{ key: ImmichCookie; value: string | null }>;
};
export class AuthDto {
@ApiProperty({ description: 'Authenticated user' })
user!: AuthUser;
@ApiPropertyOptional({ description: 'API key (if authenticated via API key)' })
apiKey?: AuthApiKey;
@ApiPropertyOptional({ description: 'Session (if authenticated via session)' })
session?: AuthSession;
}
export class LoginCredentialDto {
@ApiProperty({ example: 'testuser@email.com', description: 'User email' })
@IsEmail({ require_tld: false })
@Transform(toEmail)
@IsNotEmpty()
email!: string;
@ApiProperty({ example: 'password', description: 'User password' })
@IsString()
@IsNotEmpty()
password!: string;
}
export class LoginResponseDto {
@ApiProperty({ description: 'Access token' })
accessToken!: string;
@ApiProperty({ description: 'User ID' })
userId!: string;
@ApiProperty({ description: 'User email' })
userEmail!: string;
@ApiProperty({ description: 'User name' })
name!: string;
@ApiProperty({ description: 'Is admin user' })
isAdmin!: boolean;
@ApiProperty({ description: 'Should change password' })
shouldChangePassword!: boolean;
}
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
return {
accessToken,
userId: entity.id,
userEmail: entity.email,
name: entity.name,
isAdmin: entity.isAdmin,
shouldChangePassword: entity.shouldChangePassword,
};
}
export class LogoutResponseDto {
@ApiProperty({ description: 'Logout successful' })
successful!: boolean;
@ApiProperty({ description: 'Redirect URI' })
redirectUri!: string;
}
export class SignUpDto extends LoginCredentialDto {
@ApiProperty({ example: 'Admin', description: 'User name' })
@IsString()
@IsNotEmpty()
name!: string;
}
export class ChangePasswordDto {
@ApiProperty({ example: 'password', description: 'Current password' })
@IsString()
@IsNotEmpty()
password!: string;
@ApiProperty({ example: 'password', description: 'New password (min 8 characters)' })
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword!: string;
@ValidateBoolean({ optional: true, default: false, description: 'Invalidate all other sessions' })
invalidateSessions?: boolean;
}
export class ValidateAccessTokenResponseDto {
@ApiProperty({ description: 'Authentication status' })
authStatus!: boolean;
}
+61
View File
@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional, ApiResponseProperty } from '@nestjs/swagger';
import { SemVer } from 'semver';
export class ServerPingResponse {
@ApiResponseProperty({ type: String, example: 'pong' })
res!: string;
}
export class ServerAboutResponseDto {
@ApiProperty({ description: 'Server version' })
version!: string;
@ApiProperty({ description: 'URL to version information' })
versionUrl!: string;
@ApiPropertyOptional({ description: 'Repository name' })
repository?: string;
@ApiPropertyOptional({ description: 'Repository URL' })
repositoryUrl?: string;
@ApiPropertyOptional({ description: 'Source reference (branch/tag)' })
sourceRef?: string;
@ApiPropertyOptional({ description: 'Source commit hash' })
sourceCommit?: string;
@ApiPropertyOptional({ description: 'Source URL' })
sourceUrl?: string;
@ApiPropertyOptional({ description: 'Build identifier' })
build?: string;
@ApiPropertyOptional({ description: 'Build URL' })
buildUrl?: string;
@ApiPropertyOptional({ description: 'Node.js version' })
nodejs?: string;
}
export class ServerVersionResponseDto {
@ApiProperty({ type: 'integer', description: 'Major version number' })
major!: number;
@ApiProperty({ type: 'integer', description: 'Minor version number' })
minor!: number;
@ApiProperty({ type: 'integer', description: 'Patch version number' })
patch!: number;
static fromSemVer(value: SemVer) {
return { major: value.major, minor: value.minor, patch: value.patch };
}
}
export class ServerConfigDto {
@ApiProperty({ description: 'Login page message' })
loginPageMessage!: string;
@ApiProperty({ type: 'integer', description: 'Delay in days before deleted users are permanently removed' })
userDeleteDelay!: number;
@ApiProperty({ description: 'Whether the server has been initialized' })
isInitialized!: boolean;
}
export class ServerFeaturesDto {
@ApiProperty({ description: 'Whether password login is enabled' })
passwordLogin!: boolean;
}
+66
View File
@@ -0,0 +1,66 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Equals, IsInt, IsPositive, IsString } from 'class-validator';
import { Session } from 'src/database';
import { Optional, ValidateBoolean } from 'src/validation';
export class SessionCreateDto {
@ApiPropertyOptional({ type: 'number', description: 'Session duration in seconds' })
@IsInt()
@IsPositive()
@Optional()
duration?: number;
@ApiPropertyOptional({ description: 'Device type' })
@IsString()
@Optional()
deviceType?: string;
@ApiPropertyOptional({ description: 'Device OS' })
@IsString()
@Optional()
deviceOS?: string;
}
export class SessionUpdateDto {
@ValidateBoolean({ optional: true, description: 'Reset pending sync state' })
@Equals(true)
isPendingSyncReset?: true;
}
export class SessionResponseDto {
@ApiProperty({ description: 'Session ID' })
id!: string;
@ApiProperty({ description: 'Creation date' })
createdAt!: string;
@ApiProperty({ description: 'Last update date' })
updatedAt!: string;
@ApiPropertyOptional({ description: 'Expiration date' })
expiresAt?: string;
@ApiProperty({ description: 'Is current session' })
current!: boolean;
@ApiProperty({ description: 'Device type' })
deviceType!: string;
@ApiProperty({ description: 'Device OS' })
deviceOS!: string;
@ApiProperty({ description: 'App version' })
appVersion!: string | null;
@ApiProperty({ description: 'Is pending sync reset' })
isPendingSyncReset!: boolean;
}
export class SessionCreateResponseDto extends SessionResponseDto {
@ApiProperty({ description: 'Session token' })
token!: string;
}
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
expiresAt: entity.expiresAt?.toISOString(),
current: currentId === entity.id,
appVersion: entity.appVersion,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
isPendingSyncReset: entity.isPendingSyncReset,
});
+64
View File
@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { User, UserAdmin } from 'src/database';
import { UserStatus } from 'src/enum';
import { Optional, ValidateEnum, toEmail } from 'src/validation';
export class UserUpdateMeDto {
@ApiPropertyOptional({ description: 'User email' })
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
email?: string;
@ApiPropertyOptional({ description: 'User name' })
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
}
export class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id!: string;
@ApiProperty({ description: 'User name' })
name!: string;
@ApiProperty({ description: 'User email' })
email!: string;
}
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
return {
id: entity.id,
email: entity.email,
name: entity.name,
};
};
export class UserAdminResponseDto extends UserResponseDto {
@ApiProperty({ description: 'Require password change on next login' })
shouldChangePassword!: boolean;
@ApiProperty({ description: 'Is admin user' })
isAdmin!: boolean;
@ApiProperty({ description: 'Creation date' })
createdAt!: Date;
@ApiProperty({ description: 'Deletion date' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Last update date' })
updatedAt!: Date;
@ValidateEnum({ enum: UserStatus, name: 'UserStatus', description: 'User status' })
status!: string;
}
export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
return {
...mapUser(entity),
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
createdAt: entity.createdAt,
deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
status: entity.status,
};
}
+174
View File
@@ -0,0 +1,174 @@
export enum AuthType {
Password = 'password',
}
export enum ImmichCookie {
AccessToken = 'immich_access_token',
AuthType = 'immich_auth_type',
IsAuthenticated = 'immich_is_authenticated',
}
export enum ImmichHeader {
ApiKey = 'x-api-key',
UserToken = 'x-immich-user-token',
SessionToken = 'x-immich-session-token',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
}
export enum ImmichQuery {
ApiKey = 'apiKey',
SessionKey = 'sessionKey',
}
export enum DatabaseAction {
Create = 'CREATE',
Update = 'UPDATE',
Delete = 'DELETE',
}
export enum Permission {
All = 'all',
ApiKeyCreate = 'apiKey.create',
ApiKeyRead = 'apiKey.read',
ApiKeyUpdate = 'apiKey.update',
ApiKeyDelete = 'apiKey.delete',
AuthChangePassword = 'auth.changePassword',
AuthDeviceDelete = 'authDevice.delete',
SessionCreate = 'session.create',
SessionRead = 'session.read',
SessionUpdate = 'session.update',
SessionDelete = 'session.delete',
SystemMetadataRead = 'systemMetadata.read',
SystemMetadataUpdate = 'systemMetadata.update',
UserRead = 'user.read',
UserUpdate = 'user.update',
ServerAbout = 'server.about',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
}
export enum UserStatus {
Active = 'active',
Removing = 'removing',
Deleted = 'deleted',
}
export enum SystemMetadataKey {
AdminOnboarding = 'admin-onboarding',
SystemConfig = 'system-config',
SystemFlags = 'system-flags',
}
export enum LogLevel {
Verbose = 'verbose',
Debug = 'debug',
Log = 'log',
Warn = 'warn',
Error = 'error',
Fatal = 'fatal',
}
export enum LogFormat {
Console = 'console',
Json = 'json',
}
export enum ApiCustomExtension {
Permission = 'x-immich-permission',
AdminOnly = 'x-immich-admin-only',
History = 'x-immich-history',
State = 'x-immich-state',
}
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
SharedRoute = 'shared_route',
ApiKeySecurity = 'api_key',
EventConfig = 'event_config',
JobConfig = 'job_config',
TelemetryEnabled = 'telemetry_enabled',
}
export enum RouteKey {
User = 'users',
}
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',
None = 'none',
}
export enum ImmichEnvironment {
Development = 'development',
Testing = 'testing',
Production = 'production',
}
export enum ImmichWorker {
Api = 'api',
Microservices = 'microservices',
}
export enum ImmichTelemetry {
Host = 'host',
Api = 'api',
Io = 'io',
Repo = 'repo',
Job = 'job',
}
export enum DatabaseExtension {
Vector = 'vector',
}
export enum BootstrapEventPriority {
DatabaseService = -200,
JobService = -190,
SystemConfig = 100,
}
export enum QueueName {
BackgroundTask = 'backgroundTask',
}
export enum JobName {
UserDeleteCheck = 'UserDeleteCheck',
UserDelete = 'UserDelete',
SessionCleanup = 'SessionCleanup',
}
export enum JobStatus {
Success = 'success',
Failed = 'failed',
Skipped = 'skipped',
}
export enum DatabaseLock {
Migrations = 200,
GetSystemConfig = 69,
}
export enum ExitCode {
AppRestart = 7,
}
export enum ApiTag {
ApiKeys = 'API keys',
Authentication = 'Authentication',
Server = 'Server',
Sessions = 'Sessions',
Users = 'Users',
}
+85
View File
@@ -0,0 +1,85 @@
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { ExitCode, ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
/**
* Manages worker lifecycle
*/
class Workers {
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
restarting = false;
bootstrap() {
const { workers } = new ConfigRepository().getEnv();
for (const worker of workers) {
this.startWorker(worker);
}
}
private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
if (Object.keys(this.workers).length === 0) {
void this.bootstrap();
this.restarting = false;
}
return;
}
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}
process.exit(exitCode);
}
}
function main() {
process.title = 'server';
void new Workers().bootstrap();
}
void main();
+98
View File
@@ -0,0 +1,98 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiExtension, ApiOkResponse, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiCustomExtension, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { getUserAgentDetails } from 'src/utils/request';
type AdminRoute = { admin?: true };
type AuthenticatedOptions = { permission?: Permission | false } & AdminRoute;
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(MetadataKey.ApiKeySecurity),
SetMetadata(MetadataKey.AuthRoute, options),
];
if ((options as AdminRoute).admin) {
decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true));
}
if (options?.permission) {
decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission));
}
return applyDecorators(...decorators);
};
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<AuthenticatedRequest>().user;
});
export const FileResponse = () =>
ApiOkResponse({
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
});
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
const request = context.switchToHttp().getRequest<Request>();
const { deviceType, deviceOS, appVersion } = getUserAgentDetails(request.headers);
return {
clientIp: request.ip ?? '',
isSecure: request.secure,
deviceType,
deviceOS,
appVersion,
};
});
export interface AuthRequest extends Request {
user?: AuthDto;
}
export interface AuthenticatedRequest extends Request {
user: AuthDto;
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private authService: AuthService,
) {
this.logger.setContext(AuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, targets);
if (!options) {
return true;
}
const { admin: adminRoute, permission } = { admin: false, ...options };
const request = context.switchToHttp().getRequest<AuthRequest>();
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute, permission, uri: request.path },
});
return true;
}
}
@@ -0,0 +1,35 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { routeToErrorMessage } from 'src/utils/misc';
@Injectable()
export class ErrorInterceptor implements NestInterceptor {
constructor(private logger: LoggingRepository) {
this.logger.setContext(ErrorInterceptor.name);
}
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
return next.handle().pipe(
catchError((error) =>
throwError(() => {
if (error instanceof HttpException) {
return error;
}
this.logger.error(`Error in handler: ${error}`, error instanceof Error ? error.stack : undefined);
const message = routeToErrorMessage(context.getHandler().name);
return new InternalServerErrorException(message);
}),
),
);
}
}
@@ -0,0 +1,53 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { LoggingRepository } from 'src/repositories/logging.repository';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
constructor(
private logger: LoggingRepository,
private cls: ClsService,
) {
this.logger.setContext(GlobalExceptionFilter.name);
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
}
private fromError(error: Error) {
this.logger.error(`Unhandled exception: ${error}`, error instanceof Error ? error.stack : undefined);
if (error instanceof HttpException) {
const status = error.getStatus();
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
return { status, body };
}
return {
status: 500,
body: {
message: 'Internal server error',
},
};
}
}
@@ -0,0 +1,47 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, finalize } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository';
const maxArrayLength = 100;
const replacer = (key: string, value: unknown) => {
if (key.toLowerCase().includes('password')) {
return '********';
}
if (Array.isArray(value) && value.length > maxArrayLength) {
return [...value.slice(0, maxArrayLength), `...and ${value.length - maxArrayLength} more`];
}
return value;
};
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private logger: LoggingRepository) {
this.logger.setContext(LoggingInterceptor.name);
}
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const handler = context.switchToHttp();
const req = handler.getRequest<Request>();
const res = handler.getResponse<Response>();
const { method, ip, url } = req;
const start = performance.now();
return next.handle().pipe(
finalize(() => {
const finish = performance.now();
const duration = (finish - start).toFixed(2);
const { statusCode } = res;
this.logger.debug(`${method} ${url} ${statusCode} ${duration}ms ${ip}`);
if (req.body && Object.keys(req.body).length > 0) {
this.logger.verbose(JSON.stringify(req.body, replacer));
}
}),
);
}
}
@@ -0,0 +1,21 @@
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { ConfigRepository } from 'src/repositories/config.repository';
export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
super(app);
}
createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get(ConfigRepository).getEnv();
const server = super.createIOServer(port, options);
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
return server;
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
const asUuid = (id: string) => sql<string>`${id}::uuid`;
@Injectable()
export class ApiKeyRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<ApiKeyTable>) {
return this.db.insertInto('api_key').values(dto).returning(columns.apiKey).executeTakeFirstOrThrow();
}
async update(userId: string, id: string, dto: Updateable<ApiKeyTable>) {
return this.db
.updateTable('api_key')
.set(dto)
.where('api_key.userId', '=', userId)
.where('id', '=', asUuid(id))
.returning(columns.apiKey)
.executeTakeFirstOrThrow();
}
async delete(userId: string, id: string) {
await this.db.deleteFrom('api_key').where('userId', '=', userId).where('id', '=', asUuid(id)).execute();
}
@GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string) {
return this.db
.selectFrom('api_key')
.select((eb) => [
...columns.authApiKey,
jsonObjectFrom(
eb
.selectFrom('user')
.select(columns.authUser)
.whereRef('user.id', '=', 'api_key.userId')
.where('user.deletedAt', 'is', null),
).as('user'),
])
.where('api_key.key', '=', hashedToken)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getById(userId: string, id: string) {
return this.db
.selectFrom('api_key')
.select(columns.apiKey)
.where('id', '=', asUuid(id))
.where('userId', '=', userId)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string) {
return this.db
.selectFrom('api_key')
.select(columns.apiKey)
.where('userId', '=', userId)
.orderBy('createdAt', 'desc')
.execute();
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class AppRepository {
private closeFn?: () => Promise<void>;
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));
// in exceptional circumstance, the application may hang
setTimeout(() => process.exit(ExitCode.AppRestart), 2000);
/* eslint-enable unicorn/no-process-exit */
}
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
}
@@ -0,0 +1,244 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq';
import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join } from 'node:path';
import { excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import {
DatabaseExtension,
ImmichEnvironment,
ImmichHeader,
ImmichTelemetry,
ImmichWorker,
LogFormat,
LogLevel,
QueueName,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set';
export interface EnvData {
host?: string;
port: number;
environment: ImmichEnvironment;
configFile?: string;
logLevel?: LogLevel;
logFormat?: LogFormat;
buildMetadata: {
build?: string;
buildUrl?: string;
repository?: string;
repositoryUrl?: string;
sourceRef?: string;
sourceCommit?: string;
sourceUrl?: string;
};
bull: {
config: QueueOptions;
queues: RegisterQueueOptions[];
};
cls: {
config: ClsModuleOptions;
};
database: {
config: DatabaseConnectionParams;
skipMigrations: boolean;
vectorExtension?: VectorExtension;
};
network: {
trustedProxies: string[];
};
otel: OpenTelemetryModuleOptions;
resourcePaths: {
web: {
root: string;
indexHtml: string;
};
};
redis: RedisOptions;
telemetry: {
apiPort: number;
microservicesPort: number;
metrics: Set<ImmichTelemetry>;
};
workers: ImmichWorker[];
noColor: boolean;
}
const WORKER_TYPES = new Set(Object.values(ImmichWorker));
const asSet = <T>(value: string | undefined, defaults: T[]) => {
const values = (value || '').replaceAll(/\s/g, '').split(',').filter(Boolean);
return new Set(values.length === 0 ? defaults : (values as T[]));
};
const getEnv = (): EnvData => {
const env = process.env;
const includedWorkers = asSet(env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]);
const excludedWorkers = asSet(env.IMMICH_WORKERS_EXCLUDE, []);
const workers = [...setDifference(includedWorkers, excludedWorkers)];
for (const worker of workers) {
if (!WORKER_TYPES.has(worker)) {
throw new Error(`Invalid worker(s) found: ${workers.join(',')}`);
}
}
const environment = (env.IMMICH_ENV as ImmichEnvironment) || ImmichEnvironment.Production;
const buildFolder = env.IMMICH_BUILD_DATA || '/build';
const webFolder = join(buildFolder, 'www');
const redisConfig: RedisOptions = {
host: env.REDIS_HOSTNAME || 'redis',
port: Number(env.REDIS_PORT) || 6379,
db: Number(env.REDIS_DBINDEX) || 0,
username: env.REDIS_USERNAME || undefined,
password: env.REDIS_PASSWORD || undefined,
path: env.REDIS_SOCKET || undefined,
};
const includedTelemetries =
env.IMMICH_TELEMETRY_INCLUDE === 'all'
? new Set(Object.values(ImmichTelemetry))
: asSet<ImmichTelemetry>(env.IMMICH_TELEMETRY_INCLUDE, []);
const excludedTelemetries = asSet<ImmichTelemetry>(env.IMMICH_TELEMETRY_EXCLUDE, []);
const telemetries = setDifference(includedTelemetries, excludedTelemetries);
const databaseConnection: DatabaseConnectionParams = env.DB_URL
? { connectionType: 'url', url: env.DB_URL }
: {
connectionType: 'parts',
host: env.DB_HOSTNAME || 'database',
port: Number(env.DB_PORT) || 5432,
username: env.DB_USERNAME || 'postgres',
password: env.DB_PASSWORD || 'postgres',
database: env.DB_DATABASE_NAME || 'immichtemplate',
ssl: env.DB_SSL_MODE || undefined,
};
return {
host: env.IMMICH_HOST,
port: Number(env.IMMICH_PORT) || 2283,
environment,
configFile: env.IMMICH_CONFIG_FILE,
logLevel: env.IMMICH_LOG_LEVEL as LogLevel | undefined,
logFormat: (env.IMMICH_LOG_FORMAT as LogFormat) || LogFormat.Console,
buildMetadata: {
build: env.IMMICH_BUILD,
buildUrl: env.IMMICH_BUILD_URL,
repository: env.IMMICH_REPOSITORY,
repositoryUrl: env.IMMICH_REPOSITORY_URL,
sourceRef: env.IMMICH_SOURCE_REF,
sourceCommit: env.IMMICH_SOURCE_COMMIT,
sourceUrl: env.IMMICH_SOURCE_URL,
},
bull: {
config: {
prefix: 'immich_bull',
connection: { ...redisConfig },
defaultJobOptions: {
attempts: 1,
removeOnComplete: true,
removeOnFail: false,
},
},
queues: Object.values(QueueName).map((name) => ({ name })),
},
cls: {
config: {
middleware: {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid);
},
},
},
},
database: {
config: databaseConnection,
skipMigrations: env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: DatabaseExtension.Vector,
},
network: {
trustedProxies: ['linklocal', 'uniquelocal'],
},
otel: {
metrics: {
hostMetrics: telemetries.has(ImmichTelemetry.Host),
apiMetrics: {
enable: telemetries.has(ImmichTelemetry.Api),
ignoreRoutes: excludePaths,
},
},
},
redis: redisConfig,
resourcePaths: {
web: {
root: webFolder,
indexHtml: join(webFolder, 'index.html'),
},
},
telemetry: {
apiPort: Number(env.IMMICH_API_METRICS_PORT) || 8081,
microservicesPort: Number(env.IMMICH_MICROSERVICES_METRICS_PORT) || 8082,
metrics: telemetries,
},
workers,
noColor: !!env.NO_COLOR,
};
};
let cached: EnvData | undefined;
@Injectable()
@Telemetry({ enabled: false })
export class ConfigRepository {
constructor(@Inject(IWorker) @Optional() private worker?: ImmichWorker) {}
getEnv() {
if (!cached) {
cached = getEnv();
}
return cached;
}
isDev() {
return this.getEnv().environment === ImmichEnvironment.Development;
}
getWorker() {
return this.worker;
}
}
export const clearEnvCache = () => (cached = undefined);
@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import jwt from 'jsonwebtoken';
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
@Injectable()
export class CryptoRepository {
randomUUID(): string {
return randomUUID();
}
randomBytes(size: number) {
return randomBytes(size);
}
hashBcrypt(data: string | Buffer, saltOrRounds: string | number) {
return hash(data, saltOrRounds);
}
compareBcrypt(data: string | Buffer, encrypted: string) {
return compareSync(data, encrypted);
}
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
}
verifySha256(value: string, encryptedValue: string, publicKey: string) {
const publicKeyBuffer = Buffer.from(publicKey, 'base64');
const cryptoPublicKey = createPublicKey({
key: publicKeyBuffer,
type: 'spki',
format: 'pem',
});
const verifier = createVerify('SHA256');
verifier.update(value);
verifier.end();
const encryptedValueBuffer = Buffer.from(encryptedValue, 'base64');
return verifier.verify(cryptoPublicKey, encryptedValueBuffer);
}
hashSha1(value: string | Buffer): Buffer {
return createHash('sha1').update(value).digest();
}
hashFile(filepath: string | Buffer): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const hash = createHash('sha1');
const stream = createReadStream(filepath);
stream.on('error', (error) => reject(error));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest()));
});
}
randomBytesAsText(bytes: number) {
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
}
signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string {
return jwt.sign(payload, secret, { algorithm: 'HS256', ...options });
}
verifyJwt<T = any>(token: string, secret: string): T {
return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T;
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE } from 'src/constants';
import { DatabaseExtension, DatabaseLock } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
@Injectable()
export class DatabaseRepository {
private readonly asyncLock = new AsyncLock();
constructor(
@InjectKysely() private db: Kysely<DB>,
private logger: LoggingRepository,
private configRepository: ConfigRepository,
) {
this.logger.setContext(DatabaseRepository.name);
}
async shutdown() {
await this.db.destroy();
}
async getPostgresVersion(): Promise<string> {
const { rows } = await sql<{ server_version: string }>`SHOW server_version`.execute(this.db);
return rows[0].server_version;
}
getPostgresVersionRange(): string {
return POSTGRES_VERSION_RANGE;
}
getExtensionVersionRange(): string {
return VECTOR_VERSION_RANGE;
}
async getExtensionVersion(name: string): Promise<string | undefined> {
const { rows } = await sql<{ installed_version: string | null }>`
SELECT installed_version
FROM pg_available_extensions
WHERE name = ${name}
`.execute(this.db);
return rows[0]?.installed_version ?? undefined;
}
async createExtension(extension: DatabaseExtension): Promise<void> {
this.logger.log(`Creating ${EXTENSION_NAMES[extension]} extension`);
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db);
}
async dropExtension(extension: DatabaseExtension): Promise<void> {
this.logger.log(`Dropping ${EXTENSION_NAMES[extension]} extension`);
await sql`DROP EXTENSION IF EXISTS ${sql.raw(extension)}`.execute(this.db);
}
async runMigrations(): Promise<void> {
this.logger.log('Running migrations');
const migrator = this.createMigrator();
const { error, results } = await migrator.migrateToLatest();
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
}
if (result.status === 'Error') {
this.logger.warn(`Migration "${result.migrationName}" failed`);
}
}
if (error) {
this.logger.error(`Migrations failed: ${error}`);
throw error;
}
this.logger.log('Finished running migrations');
}
async revertLastMigration(): Promise<string | undefined> {
this.logger.debug('Reverting last migration');
const migrator = this.createMigrator();
const { error, results } = await migrator.migrateDown();
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Reverted migration "${result.migrationName}"`);
}
if (result.status === 'Error') {
this.logger.warn(`Failed to revert migration "${result.migrationName}"`);
}
}
if (error) {
this.logger.error(`Failed to revert migrations: ${error}`);
throw error;
}
const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success');
if (!reverted) {
this.logger.debug('No migrations to revert');
return undefined;
}
this.logger.debug('Finished reverting migration');
return reverted.migrationName;
}
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
let res;
await this.asyncLock.acquire(DatabaseLock[lock], async () => {
await this.db.connection().execute(async (connection) => {
try {
await this.acquireLock(lock, connection);
res = await callback();
} finally {
await this.releaseLock(lock, connection);
}
});
});
return res as R;
}
tryLock(lock: DatabaseLock): Promise<boolean> {
return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection));
}
isBusy(lock: DatabaseLock): boolean {
return this.asyncLock.isBusy(DatabaseLock[lock]);
}
async wait(lock: DatabaseLock): Promise<void> {
await this.asyncLock.acquire(DatabaseLock[lock], () => {});
}
private async acquireLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<void> {
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection);
}
private async acquireTryLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<boolean> {
const { rows } = await sql<{
pg_try_advisory_lock: boolean;
}>`SELECT pg_try_advisory_lock(${lock})`.execute(connection);
return rows[0].pg_try_advisory_lock;
}
private async releaseLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<void> {
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
}
private createMigrator(): Migrator {
return new Migrator({
db: this.db,
migrationLockTableName: 'kysely_migrations_lock',
allowUnorderedMigrations: this.configRepository.isDev(),
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
// eslint-disable-next-line unicorn/prefer-module
migrationFolder: join(__dirname, '..', 'schema/migrations'),
}),
});
}
}
+163
View File
@@ -0,0 +1,163 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { ClassConstructor } from 'class-transformer';
import _ from 'lodash';
import { Socket } from 'socket.io';
import { SystemConfig } from 'src/config';
import { EventConfig } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem } from 'src/types';
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
type Item<T extends EmitEvent> = {
event: T;
handler: EmitHandler<T>;
priority: number;
server: boolean;
label: string;
};
type EventMap = {
// app events
'app.bootstrap': [];
'app.shutdown': [];
// config events
ConfigInit: [{ newConfig: SystemConfig }];
ConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// job events
JobRun: [QueueName, JobItem];
JobStart: [QueueName, JobItem];
JobComplete: [QueueName, JobItem];
JobSuccess: [{ job: JobItem; response?: JobStatus }];
JobError: [{ job: JobItem; error: Error | unknown }];
QueueStart: [{ name: QueueName }];
// session events
SessionDelete: [{ sessionId: string }];
// user events
UserCreate: [UserEvent];
UserTrash: [UserEvent];
UserDelete: [UserEvent];
UserRestore: [UserEvent];
AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }];
// websocket events
WebsocketConnect: [{ userId: string }];
};
type UserEvent = {
name: string;
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
status: UserStatus;
email: string;
isAdmin: boolean;
shouldChangePassword: boolean;
};
export type EmitEvent = keyof EventMap;
export type EmitHandler<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
export type ArgOf<T extends EmitEvent> = EventMap[T][0];
export type ArgsOf<T extends EmitEvent> = EventMap[T];
export type EventItem<T extends EmitEvent> = {
event: T;
handler: EmitHandler<T>;
server: boolean;
};
export type AuthFn = (client: Socket) => Promise<AuthDto>;
@Injectable()
export class EventRepository {
private emitHandlers: EmitHandlers = {};
constructor(
private moduleRef: ModuleRef,
private configRepository: ConfigRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(EventRepository.name);
}
setup({ services }: { services: ClassConstructor<unknown>[] }) {
const reflector = this.moduleRef.get(Reflector, { strict: false });
const items: Item<EmitEvent>[] = [];
const worker = this.configRepository.getWorker();
if (!worker) {
throw new Error('Unable to determine worker type');
}
for (const Service of services) {
const instance = this.moduleRef.get<any>(Service);
const ctx = Object.getPrototypeOf(instance);
for (const property of Object.getOwnPropertyNames(ctx)) {
const descriptor = Object.getOwnPropertyDescriptor(ctx, property);
if (!descriptor || descriptor.get || descriptor.set) {
continue;
}
const handler = instance[property];
if (typeof handler !== 'function') {
continue;
}
const event = reflector.get<EventConfig>(MetadataKey.EventConfig, handler);
if (!event) {
continue;
}
const workers = event.workers ?? Object.values(ImmichWorker);
if (!workers.includes(worker)) {
continue;
}
items.push({
event: event.name,
priority: event.priority || 0,
server: event.server ?? false,
handler: handler.bind(instance),
label: `${Service.name}.${handler.name}`,
});
}
}
const handlers = _.orderBy(items, ['priority'], ['asc']);
for (const handler of handlers) {
this.addHandler(handler);
}
}
private addHandler<T extends EmitEvent>(item: Item<T>): void {
const event = item.event;
if (!this.emitHandlers[event]) {
this.emitHandlers[event] = [];
}
this.emitHandlers[event]!.push(item);
}
emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
return this.onEvent({ name: event, args, server: false });
}
async onEvent<T extends EmitEvent>(event: { name: T; args: ArgsOf<T>; server: boolean }): Promise<void> {
const handlers = this.emitHandlers[event.name] || [];
for (const { handler, server } of handlers) {
if (!server && event.server) {
continue;
}
await handler(...event.args);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
export const repositories = [
ApiKeyRepository,
AppRepository,
ConfigRepository,
CryptoRepository,
DatabaseRepository,
EventRepository,
JobRepository,
LoggingRepository,
ProcessRepository,
SessionRepository,
SystemMetadataRepository,
TelemetryRepository,
UserRepository,
WebsocketRepository,
];
+192
View File
@@ -0,0 +1,192 @@
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { JobsOptions, Queue, Worker } from 'bullmq';
import { ClassConstructor } from 'class-transformer';
import { setTimeout } from 'node:timers/promises';
import { JobConfig } from 'src/decorators';
import { JobName, JobStatus, MetadataKey, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem } from 'src/types';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (data: any) => Promise<JobStatus>;
label: string;
};
@Injectable()
export class JobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private handlers: Partial<Record<JobName, JobMapItem>> = {};
constructor(
private moduleRef: ModuleRef,
private configRepository: ConfigRepository,
private eventRepository: EventRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(JobRepository.name);
}
setup(services: ClassConstructor<unknown>[]) {
const reflector = this.moduleRef.get(Reflector, { strict: false });
// discovery
for (const Service of services) {
const instance = this.moduleRef.get<any>(Service);
for (const methodName of getMethodNames(instance)) {
const handler = instance[methodName];
const config = reflector.get<JobConfig>(MetadataKey.JobConfig, handler);
if (!config) {
continue;
}
const { name: jobName, queue: queueName } = config;
const label = `${Service.name}.${handler.name}`;
// one handler per job
if (this.handlers[jobName]) {
const jobKey = getKeyByValue(JobName, jobName);
const errorMessage = `Failed to add job handler for ${label}`;
this.logger.error(
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName].label}.`,
);
throw new ImmichStartupError(errorMessage);
}
this.handlers[jobName] = {
label,
jobName,
queueName,
handler: handler.bind(instance),
};
this.logger.verbose(`Added job handler: ${jobName} => ${label}`);
}
}
// no missing handlers
for (const [jobKey, jobName] of Object.entries(JobName)) {
const item = this.handlers[jobName];
if (!item) {
const errorMessage = `Failed to find job handler for Job.${jobKey} ("${jobName}")`;
this.logger.error(
`${errorMessage}. Make sure to add the @OnJob({ name: JobName.${jobKey}, queue: QueueName.XYZ }) decorator for the new job.`,
);
throw new ImmichStartupError(errorMessage);
}
}
}
startWorkers() {
const { bull } = this.configRepository.getEnv();
for (const queueName of Object.values(QueueName)) {
this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}
}
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.Skipped;
}
return item.handler(data);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
return;
}
worker.concurrency = concurrency;
}
async isActive(name: QueueName): Promise<boolean> {
const queue = this.getQueue(name);
const count = await queue.getActiveCount();
return count > 0;
}
async isPaused(name: QueueName): Promise<boolean> {
return this.getQueue(name).isPaused();
}
pause(name: QueueName) {
return this.getQueue(name).pause();
}
resume(name: QueueName) {
return this.getQueue(name).resume();
}
empty(name: QueueName) {
return this.getQueue(name).drain();
}
private getQueueName(name: JobName) {
return (this.handlers[name] as JobMapItem).queueName;
}
async queueAll(items: JobItem[]): Promise<void> {
if (items.length === 0) {
return;
}
const itemsByQueue = {} as Record<string, { name: string; data: any; options?: JobsOptions }[]>;
for (const item of items) {
const queueName = this.getQueueName(item.name);
const job = {
name: item.name,
data: item.data || {},
};
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
itemsByQueue[queueName].push(job);
}
const promises = [];
for (const [queueName, jobs] of Object.entries(itemsByQueue)) {
const queue = this.getQueue(queueName as QueueName);
promises.push(queue.addBulk(jobs));
}
await Promise.all(promises);
}
async queue(item: JobItem): Promise<void> {
return this.queueAll([item]);
}
async waitForQueueCompletion(...queues: QueueName[]): Promise<void> {
const getPending = async () => {
const results = await Promise.all(queues.map(async (name) => ({ pending: await this.isActive(name), name })));
return results.filter(({ pending }) => pending).map(({ name }) => name);
};
let pending = await getPending();
while (pending.length > 0) {
this.logger.verbose(`Waiting for ${pending[0]} queue to stop...`);
await setTimeout(1000);
pending = await getPending();
}
}
private getQueue(queue: QueueName): Queue {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
}
}
@@ -0,0 +1,197 @@
import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls';
import { Telemetry } from 'src/decorators';
import { LogFormat, LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
type LogDetails = any;
type LogFunction = () => string;
const LOG_LEVELS = [LogLevel.Verbose, LogLevel.Debug, LogLevel.Log, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal];
enum LogColor {
RED = 31,
GREEN = 32,
YELLOW = 33,
BLUE = 34,
MAGENTA_BRIGHT = 95,
CYAN_BRIGHT = 96,
}
let appName: string | undefined;
let logLevels: LogLevel[] = [LogLevel.Log, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal];
export class MyConsoleLogger extends ConsoleLogger {
private isColorEnabled: boolean;
constructor(
private cls: ClsService | undefined,
options?: { json?: boolean; color?: boolean; context?: string },
) {
super(options?.context || MyConsoleLogger.name, {
json: options?.json ?? false,
});
this.isColorEnabled = !options?.json && (options?.color || false);
}
isLevelEnabled(level: LogLevel) {
return isLogLevelEnabled(level, logLevels);
}
formatContext(context: string): string {
let prefix = appName || '';
if (context) {
prefix += (prefix ? ':' : '') + context;
}
const correlationId = this.cls?.getId();
if (correlationId) {
prefix += `~${correlationId}`;
}
if (!prefix) {
return '';
}
return this.colors.yellow(`[${prefix}]`) + ' ';
}
private colors = {
red: (text: string) => this.withColor(text, LogColor.RED),
green: (text: string) => this.withColor(text, LogColor.GREEN),
yellow: (text: string) => this.withColor(text, LogColor.YELLOW),
blue: (text: string) => this.withColor(text, LogColor.BLUE),
magentaBright: (text: string) => this.withColor(text, LogColor.MAGENTA_BRIGHT),
cyanBright: (text: string) => this.withColor(text, LogColor.CYAN_BRIGHT),
};
private withColor(text: string, color: LogColor) {
return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text;
}
}
@Injectable({ scope: Scope.TRANSIENT })
@Telemetry({ enabled: false })
export class LoggingRepository {
private logger: MyConsoleLogger;
constructor(
@Inject(ClsService) cls: ClsService | undefined,
@Inject(ConfigRepository) configRepository: ConfigRepository | undefined,
) {
let noColor = false;
let logFormat = LogFormat.Console;
if (configRepository) {
const env = configRepository.getEnv();
noColor = env.noColor;
logFormat = env.logFormat ?? logFormat;
}
this.logger = new MyConsoleLogger(cls, {
context: LoggingRepository.name,
json: logFormat === LogFormat.Json,
color: !noColor,
});
}
static create(context?: string) {
const logger = new LoggingRepository(undefined, undefined);
if (context) {
logger.setContext(context);
}
return logger;
}
setAppName(name: string): void {
appName = name.charAt(0).toUpperCase() + name.slice(1);
}
setContext(context: string) {
this.logger.setContext(context);
}
isLevelEnabled(level: LogLevel) {
return this.logger.isLevelEnabled(level);
}
setLogLevel(level: LogLevel | false): void {
logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
}
verbose(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Verbose, message, details);
}
verboseFn(message: LogFunction, ...details: LogDetails) {
this.handleFunction(LogLevel.Verbose, message, details);
}
debug(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Debug, message, details);
}
debugFn(message: LogFunction, ...details: LogDetails) {
this.handleFunction(LogLevel.Debug, message, details);
}
log(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Log, message, details);
}
warn(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Warn, message, details);
}
error(message: string | Error, ...details: LogDetails) {
this.handleMessage(LogLevel.Error, message, details);
}
fatal(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Fatal, message, details);
}
deprecate(message: string) {
this.warn(`[Deprecated] ${message}`);
}
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
if (this.logger.isLevelEnabled(level)) {
this.handleMessage(level, message(), details);
}
}
private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) {
switch (level) {
case LogLevel.Verbose: {
this.logger.verbose(message, ...details);
break;
}
case LogLevel.Debug: {
this.logger.debug(message, ...details);
break;
}
case LogLevel.Log: {
this.logger.log(message, ...details);
break;
}
case LogLevel.Warn: {
this.logger.warn(message, ...details);
break;
}
case LogLevel.Error: {
this.logger.error(message, ...details);
break;
}
case LogLevel.Fatal: {
this.logger.fatal(message, ...details);
break;
}
}
}
}
@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
import { Duplex } from 'node:stream';
@Injectable()
export class ProcessRepository {
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
return spawn(command, args, options);
}
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
let stdinClosed = false;
let drainCallback: undefined | (() => void);
const process = this.spawn(command, args, options);
const duplex = new Duplex({
// duplex -> stdin
write(chunk, encoding, callback) {
// drain the input if process dies
if (stdinClosed) {
return callback();
}
// handle stream backpressure
if (process.stdin.write(chunk, encoding)) {
callback();
} else {
drainCallback = callback;
process.stdin.once('drain', () => {
drainCallback = undefined;
callback();
});
}
},
read() {
// no-op
},
final(callback) {
if (stdinClosed) {
callback();
} else {
process.stdin.end(callback);
}
},
});
// stdout -> duplex
process.stdout.on('data', (chunk) => {
// handle stream backpressure
if (!duplex.push(chunk)) {
process.stdout.pause();
}
});
duplex.on('resume', () => process.stdout.resume());
// end handling
let stdoutClosed = false;
function close(error?: Error) {
stdinClosed = true;
if (error) {
duplex.destroy(error);
} else if (stdoutClosed && typeof process.exitCode === 'number') {
duplex.push(null);
}
}
process.stdout.on('close', () => {
stdoutClosed = true;
close();
});
// error handling
process.on('error', close);
process.stdout.on('error', close);
process.stdin.on('error', (error) => {
if ((error as { code?: 'EPIPE' })?.code === 'EPIPE') {
try {
drainCallback!();
} catch (error) {
close(error as Error);
}
} else {
close(error);
}
});
let stderr = '';
process.stderr.on('data', (chunk) => (stderr += chunk));
process.on('exit', (code) => {
console.info(`${command} exited (${code})`);
if (code === 0) {
close();
} else {
close(new Error(`${command} non-zero exit code (${code})\n${stderr}`));
}
});
return Object.assign(duplex, { _process: process });
}
fork(...args: Parameters<typeof fork>): ReturnType<typeof fork> {
return fork(...args);
}
}
@@ -0,0 +1,116 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { SessionTable } from 'src/schema/tables/session.table';
export type SessionSearchOptions = { updatedBefore: Date };
@Injectable()
export class SessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
cleanup() {
return this.db
.deleteFrom('session')
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.db
.selectFrom('session')
.select(['id', 'expiresAt', 'pinExpiresAt'])
.where('id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async isPendingSyncReset(id: string) {
const result = await this.db
.selectFrom('session')
.select(['isPendingSyncReset'])
.where('id', '=', id)
.executeTakeFirst();
return result?.isPendingSyncReset ?? false;
}
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string) {
return this.db
.selectFrom('session')
.select((eb) => [
...columns.authSession,
jsonObjectFrom(
eb
.selectFrom('user')
.select(columns.authUser)
.whereRef('user.id', '=', 'session.userId')
.where('user.deletedAt', 'is', null),
).as('user'),
])
.where('session.token', '=', token)
.where((eb) =>
eb.or([eb('session.expiresAt', 'is', null), eb('session.expiresAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string) {
return this.db
.selectFrom('session')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'session.userId').on('user.deletedAt', 'is', null))
.selectAll('session')
.where('session.userId', '=', userId)
.where((eb) =>
eb.or([eb('session.expiresAt', 'is', null), eb('session.expiresAt', '>', DateTime.now().toJSDate())]),
)
.orderBy('session.updatedAt', 'desc')
.orderBy('session.createdAt', 'desc')
.execute();
}
create(dto: Insertable<SessionTable>) {
return this.db.insertInto('session').values(dto).returningAll().executeTakeFirstOrThrow();
}
update(id: string, dto: Updateable<SessionTable>) {
return this.db
.updateTable('session')
.set(dto)
.where('session.id', '=', id)
.returningAll()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string) {
await this.db.deleteFrom('session').where('id', '=', id).execute();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, excludeId: DummyValue.UUID }] })
async invalidate({ userId, excludeId }: { userId: string; excludeId?: string }) {
await this.db
.deleteFrom('session')
.where('userId', '=', userId)
.$if(!!excludeId, (qb) => qb.where('id', '!=', excludeId!))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) {
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readFile } from 'node:fs/promises';
import { GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
import { SystemMetadata } from 'src/types';
type Upsert = Insertable<SystemMetadataTable>;
@Injectable()
export class SystemMetadataRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: ['metadata_key'] })
async get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null> {
const metadata = await this.db
.selectFrom('system_metadata')
.select('value')
.where('key', '=', key)
.executeTakeFirst();
if (!metadata) {
return null;
}
return metadata.value as SystemMetadata[T];
}
async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> {
await this.db
.insertInto('system_metadata')
.values({ key, value } as Upsert)
.onConflict((oc) => oc.columns(['key']).doUpdateSet({ value } as Upsert))
.execute();
}
@GenerateSql({ params: ['metadata_key'] })
async delete<T extends keyof SystemMetadata>(key: T): Promise<void> {
await this.db.deleteFrom('system_metadata').where('key', '=', key).execute();
}
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf8' });
}
}
@@ -0,0 +1,178 @@
import { Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MetricOptions } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { AggregationType } from '@opentelemetry/sdk-metrics';
import { NodeSDK, contextBase } from '@opentelemetry/sdk-node';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { ClassConstructor } from 'class-transformer';
import { snakeCase, startCase } from 'lodash';
import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
type MetricGroupOptions = { enabled: boolean };
export class MetricGroupRepository {
private enabled = false;
constructor(private metricService: MetricService) {}
addToCounter(name: string, value: number, options?: MetricOptions): void {
if (this.enabled) {
this.metricService.getCounter(name, options).add(value);
}
}
addToGauge(name: string, value: number, options?: MetricOptions): void {
if (this.enabled) {
this.metricService.getUpDownCounter(name, options).add(value);
}
}
addToHistogram(name: string, value: number, options?: MetricOptions): void {
if (this.enabled) {
this.metricService.getHistogram(name, options).record(value);
}
}
configure(options: MetricGroupOptions): this {
this.enabled = options.enabled;
return this;
}
}
const aggregationBoundaries = [
0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000,
];
let instance: NodeSDK | undefined;
export const bootstrapTelemetry = (port: number) => {
if (instance) {
throw new Error('OpenTelemetry SDK already started');
}
instance = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: `immich`,
[ATTR_SERVICE_VERSION]: serverVersion.toString(),
}),
metricReader: new PrometheusExporter({ port }),
contextManager: new AsyncLocalStorageContextManager(),
instrumentations: [
new HttpInstrumentation(),
new IORedisInstrumentation(),
new NestInstrumentation(),
new PgInstrumentation(),
],
views: [
{
instrumentName: '*',
instrumentUnit: 'ms',
aggregation: {
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
options: { boundaries: aggregationBoundaries },
},
},
],
});
instance.start();
};
export const teardownTelemetry = async () => {
if (instance) {
await instance.shutdown();
instance = undefined;
}
};
@Injectable()
export class TelemetryRepository {
api: MetricGroupRepository;
host: MetricGroupRepository;
jobs: MetricGroupRepository;
repo: MetricGroupRepository;
constructor(
private metricService: MetricService,
private reflect: Reflector,
private configRepository: ConfigRepository,
private logger: LoggingRepository,
) {
const { telemetry } = this.configRepository.getEnv();
const { metrics } = telemetry;
this.api = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Api) });
this.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Host) });
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Job) });
this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Repo) });
}
setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) {
const { telemetry } = this.configRepository.getEnv();
const { metrics } = telemetry;
if (!metrics.has(ImmichTelemetry.Repo)) {
return;
}
for (const Repository of repositories) {
const isEnabled = this.reflect.get(MetadataKey.TelemetryEnabled, Repository) ?? true;
if (!isEnabled) {
this.logger.debug(`Telemetry disabled for ${Repository.name}`);
continue;
}
this.wrap(Repository);
}
}
private wrap(Repository: ClassConstructor<unknown>) {
const className = Repository.name;
const descriptors = Object.getOwnPropertyDescriptors(Repository.prototype);
const unit = 'ms';
for (const [propName, descriptor] of Object.entries(descriptors)) {
const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
if (!isMethod) {
continue;
}
const method = descriptor.value;
const propertyName = snakeCase(String(propName));
const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${propertyName}.duration`;
const histogram = this.metricService.getHistogram(metricName, {
prefix: 'immich',
description: `The elapsed time in ${unit} for the ${startCase(className)} to ${propertyName.toLowerCase()}`,
unit,
valueType: contextBase.ValueType.DOUBLE,
});
descriptor.value = function (...args: any[]) {
const start = performance.now();
const result = method.apply(this, args);
void Promise.resolve(result)
.then(() => histogram.record(performance.now() - start, {}))
.catch(() => {
// noop
});
return result;
};
copyMetadataFromFunctionToFunction(method, descriptor.value);
Object.defineProperty(Repository.prototype, propName, descriptor);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { UserStatus } from 'src/enum';
import { DB } from 'src/schema';
import { UserTable } from 'src/schema/tables/user.table';
export interface UserListFilter {
withDeleted?: boolean;
}
export interface UserFindOptions {
withDeleted?: boolean;
}
@Injectable()
export class UserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string, options?: UserFindOptions) {
return this.db
.selectFrom('user')
.select(columns.user)
.$if(!options?.withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
.where('user.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAdmin(id?: string) {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.$if(!!id, (eb) => eb.where('user.id', '=', id!))
.$if(!id, (eb) => eb.where('user.isAdmin', '=', true))
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, options?: { withPassword?: boolean }) {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.$if(!!options?.withPassword, (eb) => eb.select('password'))
.where('email', '=', email)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
}
@GenerateSql()
async hasAdmin(): Promise<boolean> {
const admin = await this.db
.selectFrom('user')
.select('user.id')
.where('user.isAdmin', '=', true)
.where('user.deletedAt', 'is', null)
.executeTakeFirst();
return !!admin;
}
@GenerateSql(
{ name: 'with deleted', params: [{ withDeleted: true }] },
{ name: 'without deleted', params: [{ withDeleted: false }] },
)
getList({ withDeleted }: UserListFilter = {}) {
return this.db
.selectFrom('user')
.select(columns.userAdmin)
.$if(!withDeleted, (eb) => eb.where('user.deletedAt', 'is', null))
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [new Date()] })
getDeletedAfter(date: Date) {
return this.db.selectFrom('user').select(['id']).where('user.deletedAt', '<', date).execute();
}
async create(dto: Insertable<UserTable>) {
return this.db.insertInto('user').values(dto).returning(columns.userAdmin).executeTakeFirstOrThrow();
}
update(id: string, dto: Updateable<UserTable>) {
return this.db
.updateTable('user')
.set(dto)
.where('user.id', '=', id)
.where('user.deletedAt', 'is', null)
.returning(columns.userAdmin)
.executeTakeFirstOrThrow();
}
delete(id: string, hard?: boolean) {
return hard
? this.db.deleteFrom('user').where('id', '=', id).execute()
: this.db
.updateTable('user')
.set({ deletedAt: new Date(), status: UserStatus.Removing })
.where('id', '=', id)
.execute();
}
@GenerateSql()
async getCount(): Promise<number> {
const result = await this.db
.selectFrom('user')
.select((eb) => eb.fn.countAll().as('count'))
.where('user.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
return Number(result.count);
}
}
@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AuthDto } from 'src/dtos/auth.dto';
import { ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
on_user_delete: [string];
on_server_version: [ServerVersionResponseDto];
on_config_update: [];
on_session_delete: [string];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
@Injectable()
export class WebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
private authFn?: AuthFn;
@WebSocketServer()
private server?: Server;
constructor(
private eventRepository: EventRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(WebsocketRepository.name);
}
afterInit(server: Server) {
this.logger.log('Initialized websocket server');
for (const event of serverEvents) {
server.on(event, (...args: ArgsOf<any>) => {
this.logger.debug(`Server event: ${event} (receive)`);
handlePromiseError(this.eventRepository.onEvent({ name: event, args, server: true }), this.logger);
});
}
}
async handleConnection(client: Socket) {
try {
this.logger.log(`Websocket Connect: ${client.id}`);
const auth = await this.authenticate(client);
await client.join(auth.user.id);
if (auth.session) {
await client.join(auth.session.id);
}
await this.eventRepository.emit('WebsocketConnect', { userId: auth.user.id });
} catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
client.emit('error', 'unauthorized');
client.disconnect();
}
}
async handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
await client.leave(client.nsp.name);
}
clientSend<T extends keyof ClientEventMap>(event: T, room: string, ...data: ClientEventMap[T]) {
this.server?.to(room).emit(event, ...data);
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.server?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.server?.serverSideEmit(event, ...args);
}
setAuthFn(fn: (client: Socket) => Promise<AuthDto>) {
this.authFn = fn;
}
private async authenticate(client: Socket) {
if (!this.authFn) {
throw new Error('Auth function not set');
}
return this.authFn(client);
}
}
+1
View File
@@ -0,0 +1 @@
// No custom PG enums needed for the template
+37
View File
@@ -0,0 +1,37 @@
import { registerFunction } from 'src/sql-tools';
export const immich_uuid_v7 = registerFunction({
name: 'immich_uuid_v7',
arguments: ['p_timestamp timestamp with time zone default clock_timestamp()'],
returnType: 'uuid',
language: 'SQL',
behavior: 'volatile',
body: `
SELECT encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
`,
});
export const updated_at = registerFunction({
name: 'updated_at',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
DECLARE
clock_timestamp TIMESTAMP := clock_timestamp();
BEGIN
new."updatedAt" = clock_timestamp;
new."updateId" = immich_uuid_v7(clock_timestamp);
return new;
END;`,
});
+38
View File
@@ -0,0 +1,38 @@
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'plpgsql', 'vector'])
@Database({ name: 'immich' })
export class ImmichDatabase {
tables = [
UserTable,
SessionTable,
ApiKeyTable,
SystemMetadataTable,
];
functions = [
immich_uuid_v7,
updated_at,
];
enum = [];
}
export interface Migrations {
id: Generated<number>;
name: string;
timestamp: Int8;
}
export interface DB {
api_key: ApiKeyTable;
migrations: Migrations;
session: SessionTable;
system_metadata: SystemMetadataTable;
user: UserTable;
}
@@ -0,0 +1,129 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
// Enable extensions
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "plpgsql"`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "vector"`.execute(db);
// UUID v7 function
await sql`
CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone DEFAULT clock_timestamp())
RETURNS uuid
LANGUAGE SQL
VOLATILE
AS $$
SELECT encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
$$
`.execute(db);
// Updated at trigger function
await sql`
CREATE OR REPLACE FUNCTION updated_at()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
DECLARE
clock_timestamp TIMESTAMP := clock_timestamp();
BEGIN
new."updatedAt" = clock_timestamp;
new."updateId" = immich_uuid_v7(clock_timestamp);
return new;
END;
$$
`.execute(db);
// User table
await db.schema
.createTable('user')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`immich_uuid_v7()`))
.addColumn('email', 'varchar', (col) => col.unique().notNull())
.addColumn('password', 'varchar', (col) => col.notNull().defaultTo(''))
.addColumn('name', 'varchar', (col) => col.notNull().defaultTo(''))
.addColumn('isAdmin', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('shouldChangePassword', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('status', 'varchar', (col) => col.notNull().defaultTo('active'))
.addColumn('createdAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updatedAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('deletedAt', 'timestamptz')
.addColumn('updateId', 'uuid', (col) => col.notNull().defaultTo(sql`immich_uuid_v7()`))
.execute();
await db.schema.createIndex('IDX_user_updatedAt_id').on('user').columns(['updatedAt', 'id']).execute();
await db.schema.createIndex('IDX_user_updateId').on('user').column('updateId').execute();
await sql`CREATE TRIGGER "user_updatedAt" BEFORE UPDATE ON "user" FOR EACH ROW EXECUTE FUNCTION updated_at()`.execute(db);
// Session table
await db.schema
.createTable('session')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`immich_uuid_v7()`))
.addColumn('token', 'varchar', (col) => col.notNull())
.addColumn('userId', 'uuid', (col) => col.notNull().references('user.id').onUpdate('cascade').onDelete('cascade'))
.addColumn('parentId', 'uuid', (col) => col.references('session.id').onUpdate('cascade').onDelete('cascade'))
.addColumn('deviceType', 'varchar', (col) => col.notNull().defaultTo(''))
.addColumn('deviceOS', 'varchar', (col) => col.notNull().defaultTo(''))
.addColumn('appVersion', 'varchar')
.addColumn('isPendingSyncReset', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('expiresAt', 'timestamptz')
.addColumn('pinExpiresAt', 'timestamptz')
.addColumn('createdAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updatedAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updateId', 'uuid', (col) => col.notNull().defaultTo(sql`immich_uuid_v7()`))
.execute();
await db.schema.createIndex('IDX_session_updateId').on('session').column('updateId').execute();
await sql`CREATE TRIGGER "session_updatedAt" BEFORE UPDATE ON "session" FOR EACH ROW EXECUTE FUNCTION updated_at()`.execute(db);
// API Key table
await db.schema
.createTable('api_key')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`immich_uuid_v7()`))
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('key', 'varchar', (col) => col.notNull())
.addColumn('userId', 'uuid', (col) => col.notNull().references('user.id').onUpdate('cascade').onDelete('cascade'))
.addColumn('permissions', sql`character varying[]`, (col) => col.notNull())
.addColumn('createdAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updatedAt', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updateId', 'uuid', (col) => col.notNull().defaultTo(sql`immich_uuid_v7()`))
.execute();
await db.schema.createIndex('IDX_api_key_updateId').on('api_key').column('updateId').execute();
await sql`CREATE TRIGGER "api_key_updatedAt" BEFORE UPDATE ON "api_key" FOR EACH ROW EXECUTE FUNCTION updated_at()`.execute(db);
// System metadata table
await db.schema
.createTable('system_metadata')
.addColumn('key', 'varchar', (col) => col.primaryKey())
.addColumn('value', 'jsonb', (col) => col.notNull())
.execute();
// Migrations tracking table
await db.schema
.createTable('migrations')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('timestamp', 'bigint', (col) => col.notNull())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('migrations').ifExists().execute();
await db.schema.dropTable('system_metadata').ifExists().execute();
await db.schema.dropTable('api_key').ifExists().execute();
await db.schema.dropTable('session').ifExists().execute();
await db.schema.dropTable('user').ifExists().execute();
await sql`DROP FUNCTION IF EXISTS updated_at CASCADE`.execute(db);
await sql`DROP FUNCTION IF EXISTS immich_uuid_v7 CASCADE`.execute(db);
await sql`DROP EXTENSION IF EXISTS vector`.execute(db);
}
+41
View File
@@ -0,0 +1,41 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { Permission } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('api_key')
@UpdatedAtTrigger('api_key_updatedAt')
export class ApiKeyTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column()
name!: string;
@Column()
key!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ array: true, type: 'character varying' })
permissions!: Permission[];
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
+56
View File
@@ -0,0 +1,56 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table({ name: 'session' })
@UpdatedAtTrigger('session_updatedAt')
export class SessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
// TODO convert to byte[]
@Column()
token!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ type: 'timestamp with time zone', nullable: true })
expiresAt!: Timestamp | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
parentId!: string | null;
@Column({ default: '' })
deviceType!: Generated<string>;
@Column({ default: '' })
deviceOS!: Generated<string>;
@Column({ nullable: true })
appVersion!: string | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ type: 'boolean', default: false })
isPendingSyncReset!: Generated<boolean>;
@Column({ type: 'timestamp with time zone', nullable: true })
pinExpiresAt!: Timestamp | null;
}
@@ -0,0 +1,12 @@
import { SystemMetadataKey } from 'src/enum';
import { Column, PrimaryColumn, Table } from 'src/sql-tools';
import { SystemMetadata } from 'src/types';
@Table('system_metadata')
export class SystemMetadataTable<T extends keyof SystemMetadata = SystemMetadataKey> {
@PrimaryColumn({ type: 'character varying' })
key!: T;
@Column({ type: 'jsonb' })
value!: SystemMetadata[T];
}
+51
View File
@@ -0,0 +1,51 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserStatus } from 'src/enum';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('user')
@UpdatedAtTrigger('user_updatedAt')
@Index({ columns: ['updatedAt', 'id'] })
export class UserTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ unique: true })
email!: string;
@Column({ default: '' })
password!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'boolean', default: false })
isAdmin!: Generated<boolean>;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ default: '' })
name!: Generated<string>;
@Column({ type: 'character varying', default: UserStatus.Active })
status!: Generated<UserStatus>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

Some files were not shown because too many files have changed in this diff Show More