mirror of
https://github.com/immich-app/project-template.git
synced 2026-09-30 13:32:55 +08:00
first commit
This commit is contained in:
+23
@@ -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*
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd /usr/src/app
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
exec "$@"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"jsonRecursiveSort": true,
|
||||
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
||||
"plugins": ["prettier-plugin-sort-json"]
|
||||
}
|
||||
+2410
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+18
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "7.8.0"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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}}
|
||||
+13
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
tsconfig.json
|
||||
src/
|
||||
@@ -0,0 +1 @@
|
||||
24.13.0
|
||||
@@ -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 });
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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().');
|
||||
}
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+11691
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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/**
|
||||
@@ -0,0 +1,5 @@
|
||||
src
|
||||
tsconfig*
|
||||
eslint*
|
||||
pnpm*
|
||||
coverage
|
||||
@@ -0,0 +1 @@
|
||||
24.13.0
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"plugins": ["prettier-plugin-organize-imports"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+49
@@ -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"
|
||||
Executable
+9
@@ -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 -- "$@"
|
||||
Executable
+67
@@ -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
|
||||
@@ -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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// No custom PG enums needed for the template
|
||||
@@ -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;`,
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user