Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files

This commit is contained in:
Jonathan Jogenfors
2025-11-28 00:42:24 +01:00
834 changed files with 52374 additions and 14958 deletions
+1 -1
View File
@@ -1 +1 @@
24.11.0
24.11.1
+25 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS builder
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -48,7 +48,28 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
pnpm --filter @immich/sdk --filter @immich/cli build && \
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
FROM ghcr.io/immich-app/base-server-prod:202510281104@sha256:84f8f3eb4cfafc5e624235f7db703e1222fd60831bef1d488d8d8cad2be5023d
FROM builder AS plugins
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /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,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,target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
WORKDIR /usr/src/app
ENV NODE_ENV=production \
@@ -58,6 +79,8 @@ ENV NODE_ENV=production \
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
+12 -12
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202510281104@sha256:e2f94c2e92cbae5982b014e610ff29731c0fbcb4bf69022c7fe27594e40c9f83 AS dev
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
@@ -44,18 +44,18 @@ 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; \
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; \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg; \
fi
# Flutter SDK
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
if [ "$IMMICH_ENV" != "development" ]; then
if [[ "$IMMICH_ENV" == "production" ]]; then
echo "This command can only be run in development environments"
exit 1
fi
+66
View File
@@ -0,0 +1,66 @@
[tasks.install]
run = "pnpm install --filter immich --frozen-lockfile"
[tasks.build]
env._.path = "./node_modules/.bin"
run = "nest build"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vitest --config test/vitest.config.mjs"
[tasks."test-medium"]
env._.path = "./node_modules/.bin"
run = "vitest --config test/vitest.config.medium.mjs"
[tasks.format]
env._.path = "./node_modules/.bin"
run = "prettier --check ."
[tasks."format-fix"]
env._.path = "./node_modules/.bin"
run = "prettier --write ."
[tasks.lint]
env._.path = "./node_modules/.bin"
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
[tasks."lint-fix"]
run = { task = "lint --fix" }
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.sql]
run = "node ./dist/bin/sync-open-api.js"
[tasks."open-api"]
run = "node ./dist/bin/sync-open-api.js"
[tasks.migrations]
run = "node ./dist/bin/migrations.js"
description = "Run database migration commands (create, generate, run, debug, or query)"
[tasks."schema-drop"]
run = { task = "migrations query 'DROP schema public cascade; CREATE schema public;'" }
[tasks."schema-reset"]
run = [
{ task = ":schema-drop" },
{ task = "migrations run" },
]
[tasks."email-dev"]
env._.path = "./node_modules/.bin"
run = "email dev -p 3050 --dir src/emails"
[tasks.checklist]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test-medium --run" },
{ task = ":test --run" },
]
+15 -10
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.2.0",
"version": "2.3.1",
"description": "",
"author": "",
"private": true,
@@ -22,11 +22,11 @@
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"lifecycle": "node ./dist/utils/lifecycle.js",
"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",
@@ -34,6 +34,7 @@
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
@@ -56,6 +57,7 @@
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
@@ -68,7 +70,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.3",
"exiftool-vendored": "^31.1.0",
"exiftool-vendored": "^33.0.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@@ -76,7 +78,9 @@
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
@@ -101,7 +105,7 @@
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
@@ -116,7 +120,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4",
"@swc/core": "^1.4.14",
"@types/archiver": "^6.0.0",
"@types/archiver": "^7.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.6",
@@ -124,12 +128,13 @@
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.14.197",
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.18.12",
"@types/node": "^24.10.1",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -143,10 +148,10 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^11.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",
@@ -161,9 +166,9 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
},
"overrides": {
"sharp": "^0.34.4"
"sharp": "^0.34.5"
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { useSwagger } from 'src/utils/misc';
export function configureTelemetry() {
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
}
export async function configureExpress(
app: NestExpressApplication,
{
permitSwaggerWrite = true,
ssr,
}: {
/**
* Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available
* @default true
*/
permitSwaggerWrite?: boolean;
/**
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ssr).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}
+48 -18
View File
@@ -9,54 +9,60 @@ import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.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 { CliService } from 'src/services/cli.service';
import { JobService } from 'src/services/job.service';
import { QueueService } from 'src/services/queue.service';
import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
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 },
{ provide: APP_GUARD, useClass: AuthGuard },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const imports = [
BullModule.forRoot(bull.config),
BullModule.registerQueue(...bull.queues),
const commonImports = [
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config)),
OpenTelemetryModule.forRoot(otel),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
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 eventRepository: EventRepository,
private websocketRepository: WebsocketRepository,
private jobService: JobService,
private telemetryRepository: TelemetryRepository,
private authService: AuthService,
private eventRepository: EventRepository,
private queueService: QueueService,
private telemetryRepository: TelemetryRepository,
private websocketRepository: WebsocketRepository,
) {
logger.setAppName(this.worker);
}
@@ -64,7 +70,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
this.telemetryRepository.setup({ repositories });
this.jobService.setServices(services);
this.queueService.setServices(services);
this.websocketRepository.setAuthFn(async (client) =>
this.authService.authenticate({
@@ -85,20 +91,44 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...commonImports],
controllers: [MaintenanceWorkerController],
providers: [
ConfigRepository,
LoggingRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceWebsocketRepository,
MaintenanceWorkerService,
...commonMiddleware,
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
],
})
export class MaintenanceModule {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
) {
logger.setAppName(this.worker);
}
}
@Module({
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...bullImports, ...commonImports],
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {
+57 -1
View File
@@ -2,7 +2,7 @@
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { Kysely, sql } from 'kysely';
import { mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -27,6 +27,11 @@ const main = async () => {
return;
}
case 'revert': {
await revert();
return;
}
case 'query': {
const query = process.argv[3];
await runQuery(query);
@@ -48,6 +53,7 @@ const main = async () => {
node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name>
node dist/bin/migrations.js run
node dist/bin/migrations.js revert
`);
}
}
@@ -74,6 +80,25 @@ const runMigrations = async () => {
await db.destroy();
};
const revert = async () => {
const configRepository = new ConfigRepository();
const logger = LoggingRepository.create();
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
try {
const migrationName = await databaseRepository.revertLastMigration();
if (!migrationName) {
console.log('No migrations to revert');
return;
}
markMigrationAsReverted(migrationName);
} finally {
await db.destroy();
}
};
const debug = async () => {
const { up } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
@@ -148,6 +173,37 @@ ${downSql}
`;
};
const markMigrationAsReverted = (migrationName: string) => {
// eslint-disable-next-line unicorn/prefer-module
const distRoot = join(__dirname, '..');
const projectRoot = join(distRoot, '..');
const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations');
const distFolder = join(distRoot, 'schema', 'migrations');
const sourcePath = join(sourceFolder, `${migrationName}.ts`);
const revertedFolder = join(sourceFolder, 'reverted');
const revertedPath = join(revertedFolder, `${migrationName}.ts`);
if (existsSync(revertedPath)) {
console.log(`Migration ${migrationName} is already marked as reverted`);
} else if (existsSync(sourcePath)) {
mkdirSync(revertedFolder, { recursive: true });
renameSync(sourcePath, revertedPath);
console.log(`Moved ${sourcePath} to ${revertedPath}`);
} else {
console.warn(`Source migration file not found for ${migrationName}`);
}
const distBase = join(distFolder, migrationName);
for (const extension of ['.js', '.js.map', '.d.ts']) {
const filePath = `${distBase}${extension}`;
if (existsSync(filePath)) {
rmSync(filePath, { force: true });
console.log(`Removed ${filePath}`);
}
}
};
main()
.then(() => {
process.exit(0);
+3
View File
@@ -1,5 +1,6 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableMaintenanceModeCommand, EnableMaintenanceModeCommand } from 'src/commands/maintenance-mode';
import {
ChangeMediaLocationCommand,
PromptConfirmMoveQuestions,
@@ -16,6 +17,8 @@ export const commandsAndQuestions = [
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableMaintenanceModeCommand,
DisableMaintenanceModeCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,
+37
View File
@@ -0,0 +1,37 @@
import { Command, CommandRunner } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'enable-maintenance-mode',
description: 'Enable maintenance mode or regenerate the maintenance token',
})
export class EnableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { authUrl, alreadyEnabled } = await this.service.enableMaintenanceMode();
console.info(alreadyEnabled ? 'The server is already in maintenance mode!' : 'Maintenance mode has been enabled.');
console.info(`\nLog in using the following URL:\n${authUrl}`);
}
}
@Command({
name: 'disable-maintenance-mode',
description: 'Disable maintenance mode',
})
export class DisableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { alreadyDisabled } = await this.service.disableMaintenanceMode();
console.log(
alreadyDisabled ? 'The server is already out of maintenance mode!' : 'Maintenance mode has been disabled.',
);
}
}
+1
View File
@@ -235,6 +235,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.VideoConversion]: { concurrency: 1 },
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
},
logging: {
enabled: true,
+61 -6
View File
@@ -2,18 +2,13 @@ import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.6';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
export const NEXT_RELEASE = 'NEXT_RELEASE';
export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle';
export const DEPRECATED_IN_PREFIX = 'This property was deprecated in ';
export const ADDED_IN_PREFIX = 'This property was added in ';
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
@@ -138,3 +133,63 @@ export const ORIENTATION_TO_SHARP_ROTATION: Record<ExifOrientation, SharpRotatio
[ExifOrientation.MirrorHorizontalRotate90CW]: { angle: 90, flip: true },
[ExifOrientation.Rotate270CW]: { angle: 270 },
} as const;
export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Activities]: 'An activity is a like or a comment made by a user on an asset or album.',
[ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.',
[ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.',
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
[ApiTag.Faces]:
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
[ApiTag.Jobs]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.',
[ApiTag.Maintenance]: 'Maintenance mode allows you to put Immich in a read-only state to perform various operations.',
[ApiTag.Map]:
'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.',
[ApiTag.Memories]:
'A memory is a specialized collection of assets with dedicated viewing implementations in the web and mobile clients. A memory includes fields related to visibility and are automatically generated per user via a background job.',
[ApiTag.Notifications]:
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
[ApiTag.People]:
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
[ApiTag.Plugins]:
'A plugin is an installed module that makes filters and actions available for the workflow feature.',
[ApiTag.Queues]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Search]:
'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.',
[ApiTag.Server]:
'Information about the current server deployment, including version and build information, available features, supported media types, and more.',
[ApiTag.Sessions]:
'A session represents an authenticated login session for a user. Sessions also appear in the web application as "Authorized devices".',
[ApiTag.SharedLinks]:
'A shared link is a public url that provides access to a specific album, asset, or collection of assets. A shared link can be protected with a password, include a specific slug, allow or disallow downloads, and optionally include an expiration date.',
[ApiTag.Stacks]:
'A stack is a group of related assets. One asset is the "primary" asset, and the rest are "child" assets. On the main timeline, stack parents are included by default, while child assets are hidden.',
[ApiTag.Sync]: 'A collection of endpoints for the new mobile synchronization implementation.',
[ApiTag.SystemConfig]: 'Endpoints to view, modify, and validate the system configuration settings.',
[ApiTag.SystemMetadata]:
'Endpoints to view, modify, and validate the system metadata, which includes information about things like admin onboarding status.',
[ApiTag.Tags]:
'A tag is a user-defined label that can be applied to assets for organizational purposes. Tags can also be hierarchical, allowing for parent-child relationships between tags.',
[ApiTag.Timeline]:
'Specialized endpoints related to the timeline implementation used in the web application. External applications or tools should not use or rely on these endpoints, as they are subject to change without notice.',
[ApiTag.Trash]:
'Endpoints for managing the trash can, which includes assets that have been discarded. Items in the trash are automatically deleted after a configured amount of time.',
[ApiTag.UsersAdmin]:
'Administrative endpoints for managing users, including creating, updating, deleting, and restoring users. Also includes endpoints for resetting passwords and PIN codes.',
[ApiTag.Users]:
'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.',
[ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.',
[ApiTag.Workflows]:
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
};
+24 -2
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
ActivityCreateDto,
ActivityDto,
@@ -9,24 +10,35 @@ import {
ActivityStatisticsResponseDto,
} from 'src/dtos/activity.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ActivityService } from 'src/services/activity.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Activities')
@ApiTags(ApiTag.Activities)
@Controller('activities')
export class ActivityController {
constructor(private service: ActivityService) {}
@Get()
@Authenticated({ permission: Permission.ActivityRead })
@Endpoint({
summary: 'List all activities',
description:
'Returns a list of activities for the selected asset or album. The activities are returned in sorted order, with the oldest activities appearing first.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Post()
@Authenticated({ permission: Permission.ActivityCreate })
@Endpoint({
summary: 'Create an activity',
description: 'Create a like or a comment for an album, or an asset in an album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async createActivity(
@Auth() auth: AuthDto,
@Body() dto: ActivityCreateDto,
@@ -41,6 +53,11 @@ export class ActivityController {
@Get('statistics')
@Authenticated({ permission: Permission.ActivityStatistics })
@Endpoint({
summary: 'Retrieve activity statistics',
description: 'Returns the number of likes and comments for a given album or asset in an album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@@ -48,6 +65,11 @@ export class ActivityController {
@Delete(':id')
@Authenticated({ permission: Permission.ActivityDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete an activity',
description: 'Removes a like or comment from a given album or asset in an album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+65 -2
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AddUsersDto,
AlbumInfoDto,
@@ -14,36 +15,56 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
@ApiTags('Albums')
@ApiTags(ApiTag.Albums)
@Controller('albums')
export class AlbumController {
constructor(private service: AlbumService) {}
@Get()
@Authenticated({ permission: Permission.AlbumRead })
@Endpoint({
summary: 'List all albums',
description: 'Retrieve a list of albums available to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(auth, query);
}
@Post()
@Authenticated({ permission: Permission.AlbumCreate })
@Endpoint({
summary: 'Create an album',
description: 'Create a new album. The album can also be created with initial users and assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
return this.service.create(auth, dto);
}
@Get('statistics')
@Authenticated({ permission: Permission.AlbumStatistics })
@Endpoint({
summary: 'Retrieve album statistics',
description: 'Returns statistics about the albums available to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAlbumStatistics(@Auth() auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
return this.service.getStatistics(auth);
}
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id')
@Endpoint({
summary: 'Retrieve an album',
description: 'Retrieve information about a specific album by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -54,6 +75,12 @@ export class AlbumController {
@Patch(':id')
@Authenticated({ permission: Permission.AlbumUpdate })
@Endpoint({
summary: 'Update an album',
description:
'Update the information of a specific album by its ID. This endpoint can be used to update the album name, description, sort order, etc. However, it is not used to add or remove assets or users from the album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -65,12 +92,23 @@ export class AlbumController {
@Delete(':id')
@Authenticated({ permission: Permission.AlbumDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete an album',
description:
'Delete a specific album by its ID. Note the album is initially trashed and then immediately scheduled for deletion, but relies on a background job to complete the process.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@Put(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
@Endpoint({
summary: 'Add assets to an album',
description: 'Add multiple assets to a specific album by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
addAssetsToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -81,12 +119,22 @@ export class AlbumController {
@Put('assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
@Endpoint({
summary: 'Add assets to albums',
description: 'Send a list of asset IDs and album IDs to add each asset to each album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
return this.service.addAssetsToAlbums(auth, dto);
}
@Delete(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetDelete })
@Endpoint({
summary: 'Remove assets from an album',
description: 'Remove multiple assets from a specific album by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeAssetFromAlbum(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -97,6 +145,11 @@ export class AlbumController {
@Put(':id/users')
@Authenticated({ permission: Permission.AlbumUserCreate })
@Endpoint({
summary: 'Share album with users',
description: 'Share an album with multiple users. Each user can be given a specific role in the album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
addUsersToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -108,6 +161,11 @@ export class AlbumController {
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update user role',
description: 'Change the role for a specific user in a specific album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -120,6 +178,11 @@ export class AlbumController {
@Delete(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Remove user from album',
description: 'Remove a user from an album. Use an ID of "me" to leave a shared album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
+33 -2
View File
@@ -1,43 +1,69 @@
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 { Permission } from 'src/enum';
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('API Keys')
@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,
@@ -49,6 +75,11 @@ export class ApiKeyController {
@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);
}
@@ -15,9 +15,9 @@ import {
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { EndpointLifecycle } from 'src/decorators';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -34,7 +34,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichHeader, Permission, RouteKey } from 'src/enum';
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
@@ -44,7 +44,7 @@ import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags('Assets')
@ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset)
export class AssetMediaController {
constructor(
@@ -53,6 +53,7 @@ export class AssetMediaController {
) {}
@Post()
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiHeader({
@@ -61,7 +62,11 @@ export class AssetMediaController {
required: false,
})
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
@Endpoint({
summary: 'Upload asset',
description: 'Uploads a new asset to the server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async uploadAsset(
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@@ -81,6 +86,11 @@ export class AssetMediaController {
@Get(':id/original')
@FileResponse()
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@Endpoint({
summary: 'Download original asset',
description: 'Downloads the original file of the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -90,17 +100,13 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
}
/**
* Replace the asset with new file, without changing its id
*/
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@EndpointLifecycle({
addedAt: 'v1.106.0',
deprecatedAt: 'v1.142.0',
summary: 'replaceAsset',
description: 'Replace the asset with new file, without changing its id',
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@@ -122,6 +128,11 @@ export class AssetMediaController {
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'View asset thumbnail',
description: 'Retrieve the thumbnail image for the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async viewAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -159,6 +170,11 @@ export class AssetMediaController {
@Get(':id/video/playback')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'Play asset video',
description: 'Streams the video file for the specified asset. This endpoint also supports byte range requests.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async playAssetVideo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -168,14 +184,12 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@Authenticated()
@ApiOperation({
summary: 'checkExistingAssets',
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@@ -185,14 +199,12 @@ export class AssetMediaController {
return this.service.checkExistingAssets(auth, dto);
}
/**
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@Authenticated({ permission: Permission.AssetUpload })
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',
@Endpoint({
summary: 'Check bulk upload',
description: 'Determine which assets have already been uploaded to the server based on their SHA1 checksums.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@HttpCode(HttpStatus.OK)
checkBulkUpload(
+72 -10
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AssetBulkDeleteDto,
@@ -18,30 +18,32 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { Permission, RouteKey } from 'src/enum';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Assets')
@ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset)
export class AssetController {
constructor(private service: AssetService) {}
@Get('random')
@Authenticated({ permission: Permission.AssetRead })
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
@Endpoint({
summary: 'Get random assets',
description: 'Retrieve a specified number of random assets for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'searchAssets' }),
})
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
}
/**
* Get all asset of a device that are in the database, ID only.
*/
@Get('/device/:deviceId')
@ApiOperation({
summary: 'getAllUserAssetsByDeviceId',
@Endpoint({
summary: 'Retrieve assets by device ID',
description: 'Get all asset of a device that are in the database, ID only.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
@Authenticated()
getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) {
@@ -50,6 +52,11 @@ export class AssetController {
@Get('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@Endpoint({
summary: 'Get asset statistics',
description: 'Retrieve various statistics about the assets owned by the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetStatistics(@Auth() auth: AuthDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@@ -57,6 +64,11 @@ export class AssetController {
@Post('jobs')
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Run an asset job',
description: 'Run a specific job on a set of assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise<void> {
return this.service.run(auth, dto);
}
@@ -64,6 +76,11 @@ export class AssetController {
@Put()
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update assets',
description: 'Updates multiple assets at the same time.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@@ -71,12 +88,22 @@ export class AssetController {
@Delete()
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete assets',
description: 'Deletes multiple assets at the same time.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@Endpoint({
summary: 'Retrieve an asset',
description: 'Retrieve detailed information about a specific asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
@@ -84,12 +111,22 @@ export class AssetController {
@Put('copy')
@Authenticated({ permission: Permission.AssetCopy })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Copy asset',
description: 'Copy asset information like albums, tags, etc. from one asset to another.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
copyAsset(@Auth() auth: AuthDto, @Body() dto: AssetCopyDto): Promise<void> {
return this.service.copy(auth, dto);
}
@Put(':id')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({
summary: 'Update an asset',
description: 'Update information of a specific asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -100,18 +137,33 @@ export class AssetController {
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Get asset metadata',
description: 'Retrieve all metadata key-value pairs associated with the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
return this.service.getMetadata(auth, id);
}
@Get(':id/ocr')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve asset OCR data',
description: 'Retrieve all OCR (Optical Character Recognition) data associated with the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetOcr(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetOcrResponseDto[]> {
return this.service.getOcr(auth, id);
}
@Put(':id/metadata')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({
summary: 'Update asset metadata',
description: 'Update or add metadata key-value pairs for the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAssetMetadata(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -122,6 +174,11 @@ export class AssetController {
@Get(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve asset metadata by key',
description: 'Retrieve the value of a specific metadata key associated with the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetMetadataByKey(
@Auth() auth: AuthDto,
@Param() { id, key }: AssetMetadataRouteParams,
@@ -132,6 +189,11 @@ export class AssetController {
@Delete(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete asset metadata by key',
description: 'Delete a specific metadata key-value pair associated with the specified asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
@@ -1,17 +1,23 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuthAdminService } from 'src/services/auth-admin.service';
@ApiTags('Auth (admin)')
@ApiTags(ApiTag.AuthenticationAdmin)
@Controller('admin/auth')
export class AuthAdminController {
constructor(private service: AuthAdminService) {}
@Post('unlink-all')
@Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Unlink all OAuth accounts',
description: 'Unlinks all OAuth accounts associated with user accounts in the system.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise<void> {
return this.service.unlinkAll(auth);
}
+58 -2
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
AuthStatusResponseDto,
@@ -16,17 +17,22 @@ import {
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie, Permission } from 'src/enum';
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('Authentication')
@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,
@@ -44,11 +50,21 @@ export class AuthController {
}
@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 {
@@ -58,6 +74,11 @@ export class AuthController {
@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);
}
@@ -65,6 +86,11 @@ export class AuthController {
@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,
@@ -82,6 +108,11 @@ export class AuthController {
@Get('status')
@Authenticated()
@Endpoint({
summary: 'Retrieve auth status',
description:
'Get information about the current session, including whether the user has a password, and if the session can access locked assets.',
})
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
return this.service.getAuthStatus(auth);
}
@@ -89,6 +120,11 @@ export class AuthController {
@Post('pin-code')
@Authenticated({ permission: Permission.PinCodeCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Setup pin code',
description: 'Setup a new pin code for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@@ -96,6 +132,11 @@ export class AuthController {
@Put('pin-code')
@Authenticated({ permission: Permission.PinCodeUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Change pin code',
description: 'Change the pin code for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@@ -103,6 +144,11 @@ export class AuthController {
@Delete('pin-code')
@Authenticated({ permission: Permission.PinCodeDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Reset pin code',
description: 'Reset the pin code for the current user by providing the account password',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@@ -110,12 +156,22 @@ export class AuthController {
@Post('session/unlock')
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Unlock auth session',
description: 'Temporarily grant the session elevated access to locked assets by providing the correct PIN code.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@Authenticated()
@Endpoint({
summary: 'Lock auth session',
description: 'Remove elevated access to locked assets from the current session.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@HttpCode(HttpStatus.NO_CONTENT)
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
+15 -2
View File
@@ -1,20 +1,27 @@
import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { DownloadService } from 'src/services/download.service';
import { asStreamableFile } from 'src/utils/file';
@ApiTags('Download')
@ApiTags(ApiTag.Download)
@Controller('download')
export class DownloadController {
constructor(private service: DownloadService) {}
@Post('info')
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@Endpoint({
summary: 'Retrieve download information',
description:
'Retrieve information about how to request a download for the specified assets or album. The response includes groups of assets that can be downloaded together.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
}
@@ -23,6 +30,12 @@ export class DownloadController {
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@FileResponse()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Download asset archive',
description:
'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}
+18 -2
View File
@@ -1,20 +1,26 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Duplicates')
@ApiTags(ApiTag.Duplicates)
@Controller('duplicates')
export class DuplicateController {
constructor(private service: DuplicateService) {}
@Get()
@Authenticated({ permission: Permission.DuplicateRead })
@Endpoint({
summary: 'Retrieve duplicates',
description: 'Retrieve a list of duplicate assets available to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetDuplicates(@Auth() auth: AuthDto): Promise<DuplicateResponseDto[]> {
return this.service.getDuplicates(auth);
}
@@ -22,6 +28,11 @@ export class DuplicateController {
@Delete()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete duplicates',
description: 'Delete multiple duplicate assets specified by their IDs.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -29,6 +40,11 @@ export class DuplicateController {
@Delete(':id')
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a duplicate',
description: 'Delete a single duplicate asset specified by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+24 -2
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceCreateDto,
@@ -8,30 +9,46 @@ import {
FaceDto,
PersonResponseDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Faces')
@ApiTags(ApiTag.Faces)
@Controller('faces')
export class FaceController {
constructor(private service: PersonService) {}
@Post()
@Authenticated({ permission: Permission.FaceCreate })
@Endpoint({
summary: 'Create a face',
description:
'Create a new face that has not been discovered by facial recognition. The content of the bounding box is considered a face.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) {
return this.service.createFace(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.FaceRead })
@Endpoint({
summary: 'Retrieve faces for asset',
description: 'Retrieve all faces belonging to an asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
return this.service.getFacesById(auth, dto);
}
@Put(':id')
@Authenticated({ permission: Permission.FaceUpdate })
@Endpoint({
summary: 'Re-assign a face to another person',
description: 'Re-assign the face provided in the body to the person identified by the id in the path parameter.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
reassignFacesById(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -43,6 +60,11 @@ export class FaceController {
@Delete(':id')
@Authenticated({ permission: Permission.FaceDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a face',
description: 'Delete a face identified by the id. Optionally can be force deleted.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto): Promise<void> {
return this.service.deleteFace(auth, id, dto);
}
+8
View File
@@ -11,6 +11,7 @@ import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
@@ -18,6 +19,8 @@ import { NotificationController } from 'src/controllers/notification.controller'
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { PluginController } from 'src/controllers/plugin.controller';
import { QueueController } from 'src/controllers/queue.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerController } from 'src/controllers/server.controller';
import { SessionController } from 'src/controllers/session.controller';
@@ -32,6 +35,7 @@ import { TrashController } from 'src/controllers/trash.controller';
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserController } from 'src/controllers/user.controller';
import { ViewController } from 'src/controllers/view.controller';
import { WorkflowController } from 'src/controllers/workflow.controller';
export const controllers = [
ApiKeyController,
@@ -47,6 +51,7 @@ export const controllers = [
FaceController,
JobController,
LibraryController,
MaintenanceController,
MapController,
MemoryController,
NotificationController,
@@ -54,6 +59,8 @@ export const controllers = [
OAuthController,
PartnerController,
PersonController,
PluginController,
QueueController,
SearchController,
ServerController,
SessionController,
@@ -68,4 +75,5 @@ export const controllers = [
UserAdminController,
UserController,
ViewController,
WorkflowController,
];
+38 -10
View File
@@ -1,31 +1,59 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { JobCreateDto } from 'src/dtos/job.dto';
import { QueueResponseLegacyDto, QueuesResponseLegacyDto } from 'src/dtos/queue-legacy.dto';
import { QueueCommandDto, QueueNameParamDto } from 'src/dtos/queue.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
import { QueueService } from 'src/services/queue.service';
@ApiTags('Jobs')
@ApiTags(ApiTag.Jobs)
@Controller('jobs')
export class JobController {
constructor(private service: JobService) {}
constructor(
private service: JobService,
private queueService: QueueService,
) {}
@Get()
@Authenticated({ permission: Permission.JobRead, admin: true })
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.service.getAllJobsStatus();
@Endpoint({
summary: 'Retrieve queue counts and status',
description: 'Retrieve the counts of the current queue, as well as the current status.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'),
})
getQueuesLegacy(@Auth() auth: AuthDto): Promise<QueuesResponseLegacyDto> {
return this.queueService.getAllLegacy(auth);
}
@Post()
@Authenticated({ permission: Permission.JobCreate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Create a manual job',
description:
'Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id')
@Put(':name')
@Authenticated({ permission: Permission.JobCreate, admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
return this.service.handleCommand(id, dto);
@Endpoint({
summary: 'Run jobs',
description:
'Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'),
})
runQueueCommandLegacy(
@Param() { name }: QueueNameParamDto,
@Body() dto: QueueCommandDto,
): Promise<QueueResponseLegacyDto> {
return this.queueService.runCommandLegacy(name, dto);
}
}
+44 -2
View File
@@ -1,5 +1,6 @@
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 {
CreateLibraryDto,
LibraryResponseDto,
@@ -8,36 +9,56 @@ import {
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { LibraryService } from 'src/services/library.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Libraries')
@ApiTags(ApiTag.Libraries)
@Controller('libraries')
export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
@Authenticated({ permission: Permission.LibraryRead, admin: true })
@Endpoint({
summary: 'Retrieve libraries',
description: 'Retrieve a list of external libraries.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAllLibraries(): Promise<LibraryResponseDto[]> {
return this.service.getAll();
}
@Post()
@Authenticated({ permission: Permission.LibraryCreate, admin: true })
@Endpoint({
summary: 'Create a library',
description: 'Create a new external library.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createLibrary(@Body() dto: CreateLibraryDto): Promise<LibraryResponseDto> {
return this.service.create(dto);
}
@Get(':id')
@Authenticated({ permission: Permission.LibraryRead, admin: true })
@Endpoint({
summary: 'Retrieve a library',
description: 'Retrieve an external library by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getLibrary(@Param() { id }: UUIDParamDto): Promise<LibraryResponseDto> {
return this.service.get(id);
}
@Put(':id')
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
@Endpoint({
summary: 'Update a library',
description: 'Update an existing external library.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@@ -45,6 +66,11 @@ export class LibraryController {
@Delete(':id')
@Authenticated({ permission: Permission.LibraryDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a library',
description: 'Delete an external library by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@@ -52,6 +78,11 @@ export class LibraryController {
@Post(':id/validate')
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Validate library settings',
description: 'Validate the settings of an external library.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
// TODO: change endpoint to validate current settings instead
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
return this.service.validate(id, dto);
@@ -59,6 +90,12 @@ export class LibraryController {
@Get(':id/statistics')
@Authenticated({ permission: Permission.LibraryStatistics, admin: true })
@Endpoint({
summary: 'Retrieve library statistics',
description:
'Retrieve statistics for a specific external library, including number of videos, images, and storage usage.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(id);
}
@@ -66,6 +103,11 @@ export class LibraryController {
@Post(':id/scan')
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Scan a library',
description: 'Queue a scan for the external library to find and import new assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
scanLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.queueScan(id);
}
@@ -0,0 +1,49 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
throw new BadRequestException('Not in maintenance mode');
}
@Post()
@Endpoint({
summary: 'Set maintenance mode',
description: 'Put Immich into or take it out of maintenance mode',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async setMaintenanceMode(
@Auth() auth: AuthDto,
@Body() dto: SetMaintenanceModeDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.action === MaintenanceAction.Start) {
const { jwt } = await this.service.startMaintenance(auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}
}
+13 -1
View File
@@ -1,5 +1,6 @@
import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MapMarkerDto,
@@ -7,16 +8,22 @@ import {
MapReverseGeocodeDto,
MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto';
import { ApiTag } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service';
@ApiTags('Map')
@ApiTags(ApiTag.Map)
@Controller('map')
export class MapController {
constructor(private service: MapService) {}
@Get('markers')
@Authenticated()
@Endpoint({
summary: 'Retrieve map markers',
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(auth, options);
}
@@ -24,6 +31,11 @@ export class MapController {
@Authenticated()
@Get('reverse-geocode')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Reverse geocode coordinates',
description: 'Retrieve location information (e.g., city, country) for given latitude and longitude coordinates.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
reverseGeocode(@Query() dto: MapReverseGeocodeDto): Promise<MapReverseGeocodeResponseDto[]> {
return this.service.reverseGeocode(dto);
}
@@ -24,6 +24,11 @@ describe(MemoryController.name, () => {
await request(ctx.getHttpServer()).get('/memories');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should not require any parameters', async () => {
await request(ctx.getHttpServer()).get('/memories').query({});
expect(service.search).toHaveBeenCalled();
});
});
describe('POST /memories', () => {
+45 -2
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -9,42 +10,69 @@ import {
MemoryStatisticsResponseDto,
MemoryUpdateDto,
} from 'src/dtos/memory.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Memories')
@ApiTags(ApiTag.Memories)
@Controller('memories')
export class MemoryController {
constructor(private service: MemoryService) {}
@Get()
@Authenticated({ permission: Permission.MemoryRead })
@Endpoint({
summary: 'Retrieve memories',
description:
'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ permission: Permission.MemoryCreate })
@Endpoint({
summary: 'Create a memory',
description:
'Create a new memory by providing a name, description, and a list of asset IDs to include in the memory.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> {
return this.service.create(auth, dto);
}
@Get('statistics')
@Authenticated({ permission: Permission.MemoryStatistics })
@Endpoint({
summary: 'Retrieve memories statistics',
description: 'Retrieve statistics about memories, such as total count and other relevant metrics.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryStatisticsResponseDto> {
return this.service.statistics(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.MemoryRead })
@Endpoint({
summary: 'Retrieve a memory',
description: 'Retrieve a specific memory by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.MemoryUpdate })
@Endpoint({
summary: 'Update a memory',
description: 'Update an existing memory by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateMemory(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -56,12 +84,22 @@ export class MemoryController {
@Delete(':id')
@Authenticated({ permission: Permission.MemoryDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a memory',
description: 'Delete a specific memory by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated({ permission: Permission.MemoryAssetCreate })
@Endpoint({
summary: 'Add assets to a memory',
description: 'Add a list of asset IDs to a specific memory.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
addMemoryAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -73,6 +111,11 @@ export class MemoryController {
@Delete(':id/assets')
@Authenticated({ permission: Permission.MemoryAssetDelete })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Remove assets from a memory',
description: 'Remove a list of asset IDs from a specific memory.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeMemoryAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -1,5 +1,6 @@
import { Body, Controller, 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 {
NotificationCreateDto,
@@ -9,17 +10,23 @@ import {
TestEmailResponseDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { ApiTag } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationAdminService } from 'src/services/notification-admin.service';
@ApiTags('Notifications (Admin)')
@ApiTags(ApiTag.NotificationsAdmin)
@Controller('admin/notifications')
export class NotificationAdminController {
constructor(private service: NotificationAdminService) {}
@Post()
@Authenticated({ admin: true })
@Endpoint({
summary: 'Create a notification',
description: 'Create a new notification for a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise<NotificationDto> {
return this.service.create(auth, dto);
}
@@ -27,6 +34,11 @@ export class NotificationAdminController {
@Post('test-email')
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Send test email',
description: 'Send a test email using the provided SMTP configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto);
}
@@ -34,6 +46,11 @@ export class NotificationAdminController {
@Post('templates/:name')
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Render email template',
description: 'Retrieve a preview of the provided email template.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getNotificationTemplateAdmin(
@Auth() auth: AuthDto,
@Param('name') name: EmailTemplate,
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
NotificationDeleteAllDto,
@@ -8,18 +9,23 @@ import {
NotificationUpdateAllDto,
NotificationUpdateDto,
} from 'src/dtos/notification.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Notifications')
@ApiTags(ApiTag.Notifications)
@Controller('notifications')
export class NotificationController {
constructor(private service: NotificationService) {}
@Get()
@Authenticated({ permission: Permission.NotificationRead })
@Endpoint({
summary: 'Retrieve notifications',
description: 'Retrieve a list of notifications.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise<NotificationDto[]> {
return this.service.search(auth, dto);
}
@@ -27,6 +33,11 @@ export class NotificationController {
@Put()
@Authenticated({ permission: Permission.NotificationUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update notifications',
description: 'Update a list of notifications. Allows to bulk-set the read status of notifications.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@@ -34,18 +45,33 @@ export class NotificationController {
@Delete()
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete notifications',
description: 'Delete a list of notifications at once.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.NotificationRead })
@Endpoint({
summary: 'Get a notification',
description: 'Retrieve a specific notification identified by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<NotificationDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.NotificationUpdate })
@Endpoint({
summary: 'Update a notification',
description: 'Update a specific notification to set its read status.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateNotification(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -57,6 +83,11 @@ export class NotificationController {
@Delete(':id')
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a notification',
description: 'Delete a specific notification.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+29 -2
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
LoginResponseDto,
@@ -9,18 +10,24 @@ import {
OAuthConfigDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie } from 'src/enum';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('OAuth')
@ApiTags(ApiTag.Authentication)
@Controller('oauth')
export class OAuthController {
constructor(private service: AuthService) {}
@Get('mobile-redirect')
@Redirect()
@Endpoint({
summary: 'Redirect OAuth to mobile',
description:
'Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
redirectOAuthToMobile(@Req() request: Request) {
return {
url: this.service.getMobileRedirect(request.url),
@@ -29,6 +36,11 @@ export class OAuthController {
}
@Post('authorize')
@Endpoint({
summary: 'Start OAuth',
description: 'Initiate the OAuth authorization process.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async startOAuth(
@Body() dto: OAuthConfigDto,
@Res({ passthrough: true }) res: Response,
@@ -49,6 +61,11 @@ export class OAuthController {
}
@Post('callback')
@Endpoint({
summary: 'Finish OAuth',
description: 'Complete the OAuth authorization process by exchanging the authorization code for a session token.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async finishOAuth(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@@ -71,6 +88,11 @@ export class OAuthController {
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Link OAuth account',
description: 'Link an OAuth account to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@@ -82,6 +104,11 @@ export class OAuthController {
@Post('unlink')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Unlink OAuth account',
description: 'Unlink the OAuth account from the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
+28 -4
View File
@@ -1,32 +1,46 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Partners')
@ApiTags(ApiTag.Partners)
@Controller('partners')
export class PartnerController {
constructor(private service: PartnerService) {}
@Get()
@Authenticated({ permission: Permission.PartnerRead })
@Endpoint({
summary: 'Retrieve partners',
description: 'Retrieve a list of partners with whom assets are shared.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise<PartnerResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ permission: Permission.PartnerCreate })
@Endpoint({
summary: 'Create a partner',
description: 'Create a new partner to share assets with.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise<PartnerResponseDto> {
return this.service.create(auth, dto);
}
@Post(':id')
@EndpointLifecycle({ deprecatedAt: 'v1.141.0' })
@Endpoint({
summary: 'Create a partner',
description: 'Create a new partner to share assets with.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'createPartner' }),
})
@Authenticated({ permission: Permission.PartnerCreate })
createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, { sharedWithId: id });
@@ -34,6 +48,11 @@ export class PartnerController {
@Put(':id')
@Authenticated({ permission: Permission.PartnerUpdate })
@Endpoint({
summary: 'Update a partner',
description: "Specify whether a partner's assets should appear in the user's timeline.",
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -45,6 +64,11 @@ export class PartnerController {
@Delete(':id')
@Authenticated({ permission: Permission.PartnerDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Remove a partner',
description: 'Stop sharing assets with a partner.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+58 -2
View File
@@ -14,6 +14,7 @@ import {
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -27,14 +28,14 @@ import {
PersonStatisticsResponseDto,
PersonUpdateDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonService } from 'src/services/person.service';
import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags('People')
@ApiTags(ApiTag.People)
@Controller('people')
export class PersonController {
constructor(
@@ -46,18 +47,33 @@ export class PersonController {
@Get()
@Authenticated({ permission: Permission.PersonRead })
@Endpoint({
summary: 'Get all people',
description: 'Retrieve a list of all people.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAllPeople(@Auth() auth: AuthDto, @Query() options: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(auth, options);
}
@Post()
@Authenticated({ permission: Permission.PersonCreate })
@Endpoint({
summary: 'Create a person',
description: 'Create a new person that can have multiple faces assigned to them.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.service.create(auth, dto);
}
@Put()
@Authenticated({ permission: Permission.PersonUpdate })
@Endpoint({
summary: 'Update people',
description: 'Bulk update multiple people at once.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updateAll(auth, dto);
}
@@ -65,18 +81,33 @@ export class PersonController {
@Delete()
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete people',
description: 'Bulk delete a list of people at once.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.PersonRead })
@Endpoint({
summary: 'Get a person',
description: 'Retrieve a person by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.PersonUpdate })
@Endpoint({
summary: 'Update person',
description: 'Update an individual person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updatePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -88,12 +119,22 @@ export class PersonController {
@Delete(':id')
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete person',
description: 'Delete an individual person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Get(':id/statistics')
@Authenticated({ permission: Permission.PersonStatistics })
@Endpoint({
summary: 'Get person statistics',
description: 'Retrieve statistics about a specific person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
}
@@ -101,6 +142,11 @@ export class PersonController {
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.PersonRead })
@Endpoint({
summary: 'Get person thumbnail',
description: 'Retrieve the thumbnail file for a person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getPersonThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@@ -112,6 +158,11 @@ export class PersonController {
@Put(':id/reassign')
@Authenticated({ permission: Permission.PersonReassign })
@Endpoint({
summary: 'Reassign faces',
description: 'Bulk reassign a list of faces to a different person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -123,6 +174,11 @@ export class PersonController {
@Post(':id/merge')
@Authenticated({ permission: Permission.PersonMerge })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Merge people',
description: 'Merge a list of people into the person specified in the path parameter.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -0,0 +1,36 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Plugins')
@Controller('plugins')
export class PluginController {
constructor(private service: PluginService) {}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugins',
description: 'Retrieve a list of plugins available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPlugins(): Promise<PluginResponseDto[]> {
return this.service.getAll();
}
@Get(':id')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve a plugin',
description: 'Retrieve information about a specific plugin by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.get(id);
}
}
@@ -0,0 +1,85 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
QueueDeleteDto,
QueueJobResponseDto,
QueueJobSearchDto,
QueueNameParamDto,
QueueResponseDto,
QueueUpdateDto,
} from 'src/dtos/queue.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { QueueService } from 'src/services/queue.service';
@ApiTags(ApiTag.Queues)
@Controller('queues')
export class QueueController {
constructor(private service: QueueService) {}
@Get()
@Authenticated({ permission: Permission.QueueRead, admin: true })
@Endpoint({
summary: 'List all queues',
description: 'Retrieves a list of queues.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
getQueues(@Auth() auth: AuthDto): Promise<QueueResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':name')
@Authenticated({ permission: Permission.QueueRead, admin: true })
@Endpoint({
summary: 'Retrieve a queue',
description: 'Retrieves a specific queue by its name.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
getQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto): Promise<QueueResponseDto> {
return this.service.get(auth, name);
}
@Put(':name')
@Authenticated({ permission: Permission.QueueUpdate, admin: true })
@Endpoint({
summary: 'Update a queue',
description: 'Change the paused status of a specific queue.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
updateQueue(
@Auth() auth: AuthDto,
@Param() { name }: QueueNameParamDto,
@Body() dto: QueueUpdateDto,
): Promise<QueueResponseDto> {
return this.service.update(auth, name, dto);
}
@Get(':name/jobs')
@Authenticated({ permission: Permission.QueueJobRead, admin: true })
@Endpoint({
summary: 'Retrieve queue jobs',
description: 'Retrieves a list of queue jobs from the specified queue.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
getQueueJobs(
@Auth() auth: AuthDto,
@Param() { name }: QueueNameParamDto,
@Query() dto: QueueJobSearchDto,
): Promise<QueueJobResponseDto[]> {
return this.service.searchJobs(auth, name, dto);
}
@Delete(':name/jobs')
@Authenticated({ permission: Permission.QueueJobDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Empty a queue',
description: 'Removes all jobs from the specified queue.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
emptyQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto, @Body() dto: QueueDeleteDto): Promise<void> {
return this.service.emptyQueue(auth, name, dto);
}
}
+55 -2
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
@@ -17,11 +18,11 @@ import {
SmartSearchDto,
StatisticsSearchDto,
} from 'src/dtos/search.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SearchService } from 'src/services/search.service';
@ApiTags('Search')
@ApiTags(ApiTag.Search)
@Controller('search')
export class SearchController {
constructor(private service: SearchService) {}
@@ -29,6 +30,11 @@ export class SearchController {
@Post('metadata')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search assets by metadata',
description: 'Search for assets based on various metadata criteria.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchAssets(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@@ -36,6 +42,11 @@ export class SearchController {
@Post('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search asset statistics',
description: 'Retrieve statistical data about assets based on search criteria, such as the total matching count.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
return this.service.searchStatistics(auth, dto);
}
@@ -43,6 +54,11 @@ export class SearchController {
@Post('random')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search random assets',
description: 'Retrieve a random selection of assets based on the provided criteria.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchRandom(auth, dto);
}
@@ -50,6 +66,11 @@ export class SearchController {
@Post('large-assets')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search large assets',
description: 'Search for assets that are considered large based on specified criteria.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);
}
@@ -57,36 +78,68 @@ export class SearchController {
@Post('smart')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Smart asset search',
description: 'Perform a smart search for assets by using machine learning vectors to determine relevance.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}
@Get('explore')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve explore data',
description: 'Retrieve data for the explore section, such as popular people and places.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(auth);
}
@Get('person')
@Authenticated({ permission: Permission.PersonRead })
@Endpoint({
summary: 'Search people',
description: 'Search for people by name.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('places')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Search places',
description: 'Search for places by name.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('cities')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve assets by city',
description:
'Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetsByCity(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
return this.service.getAssetsByCity(auth);
}
@Get('suggestions')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve search suggestions',
description:
'Retrieve search suggestions based on partial input. This endpoint is used for typeahead search features.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
// TODO fix open api generation to indicate that results can be nullable
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
+78 -2
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
ServerAboutResponseDto,
@@ -15,13 +16,13 @@ import {
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerService } from 'src/services/server.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { VersionService } from 'src/services/version.service';
@ApiTags('Server')
@ApiTags(ApiTag.Server)
@Controller('server')
export class ServerController {
constructor(
@@ -32,59 +33,114 @@ export class ServerController {
@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('apk-links')
@Authenticated({ permission: Permission.ServerApkLinks })
@Endpoint({
summary: 'Get APK links',
description: 'Retrieve links to the APKs for the current server version.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApkLinks(): ServerApkLinksDto {
return this.service.getApkLinks();
}
@Get('storage')
@Authenticated({ permission: Permission.ServerStorage })
@Endpoint({
summary: 'Get storage',
description: 'Retrieve the current storage utilization information of the server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getStorage(): Promise<ServerStorageResponseDto> {
return this.service.getStorage();
}
@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.versionService.getVersion();
}
@Get('version-history')
@Endpoint({
summary: 'Get version history',
description: 'Retrieve a list of past versions the server has been on.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getVersionHistory(): Promise<ServerVersionHistoryResponseDto[]> {
return this.versionService.getVersionHistory();
}
@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('theme')
@Endpoint({
summary: 'Get theme',
description: 'Retrieve the custom CSS, if existent.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getTheme(): Promise<ServerThemeDto> {
return this.service.getTheme();
}
@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();
}
@Get('statistics')
@Authenticated({ permission: Permission.ServerStatistics, admin: true })
@Endpoint({
summary: 'Get statistics',
description: 'Retrieve statistics about the entire Immich instance such as asset counts.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getServerStatistics(): Promise<ServerStatsResponseDto> {
return this.service.getStatistics();
}
@Get('media-types')
@Endpoint({
summary: 'Get supported media types',
description: 'Retrieve all media types supported by the server.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
@@ -92,12 +148,22 @@ export class ServerController {
@Get('license')
@Authenticated({ permission: Permission.ServerLicenseRead, admin: true })
@ApiNotFoundResponse()
@Endpoint({
summary: 'Get product key',
description: 'Retrieve information about whether the server currently has a product key registered.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getServerLicense(): Promise<LicenseResponseDto> {
return this.service.getLicense();
}
@Put('license')
@Authenticated({ permission: Permission.ServerLicenseUpdate, admin: true })
@Endpoint({
summary: 'Set server product key',
description: 'Validate and set the server product key if successful.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@@ -105,12 +171,22 @@ export class ServerController {
@Delete('license')
@Authenticated({ permission: Permission.ServerLicenseDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete server product key',
description: 'Delete the currently set server product key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('version-check')
@Authenticated({ permission: Permission.ServerVersionCheck })
@Endpoint({
summary: 'Get version check status',
description: 'Retrieve information about the last time the version check ran.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getVersionCheck(): Promise<VersionCheckStateResponseDto> {
return this.systemMetadataService.getVersionCheckState();
}
+33 -2
View File
@@ -1,25 +1,36 @@
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 { AuthDto } from 'src/dtos/auth.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, SessionUpdateDto } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
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('Sessions')
@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);
}
@@ -27,12 +38,22 @@ export class SessionController {
@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);
}
@Put(':id')
@Authenticated({ permission: Permission.SessionUpdate })
@Endpoint({
summary: 'Update a session',
description: 'Update a specific session identified by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateSession(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -44,6 +65,11 @@ export class SessionController {
@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);
}
@@ -51,6 +77,11 @@ export class SessionController {
@Post(':id/lock')
@Authenticated({ permission: Permission.SessionLock })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Lock a session',
description: 'Lock a specific session by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.lock(auth, id);
}
@@ -15,6 +15,7 @@ import {
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -25,26 +26,36 @@ import {
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
import { ImmichCookie, Permission } from 'src/enum';
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Shared Links')
@ApiTags(ApiTag.SharedLinks)
@Controller('shared-links')
export class SharedLinkController {
constructor(private service: SharedLinkService) {}
@Get()
@Authenticated({ permission: Permission.SharedLinkRead })
@Endpoint({
summary: 'Retrieve all shared links',
description: 'Retrieve a list of all shared links.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Get('me')
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Retrieve current shared link',
description: 'Retrieve the current shared link associated with authentication method.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@@ -65,18 +76,33 @@ export class SharedLinkController {
@Get(':id')
@Authenticated({ permission: Permission.SharedLinkRead })
@Endpoint({
summary: 'Retrieve a shared link',
description: 'Retrieve a specific shared link by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SharedLinkResponseDto> {
return this.service.get(auth, id);
}
@Post()
@Authenticated({ permission: Permission.SharedLinkCreate })
@Endpoint({
summary: 'Create a shared link',
description: 'Create a new shared link.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(auth, dto);
}
@Patch(':id')
@Authenticated({ permission: Permission.SharedLinkUpdate })
@Endpoint({
summary: 'Update a shared link',
description: 'Update an existing shared link by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateSharedLink(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -88,12 +114,23 @@ export class SharedLinkController {
@Delete(':id')
@Authenticated({ permission: Permission.SharedLinkDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a shared link',
description: 'Delete a specific shared link by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Add assets to a shared link',
description:
'Add assets to a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
addSharedLinkAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -104,6 +141,12 @@ export class SharedLinkController {
@Delete(':id/assets')
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Remove assets from a shared link',
description:
'Remove assets from a specific shared link by its ID. This endpoint is only relevant for shared link of type individual.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeSharedLinkAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
+39 -2
View File
@@ -1,26 +1,38 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { StackService } from 'src/services/stack.service';
import { UUIDAssetIDParamDto, UUIDParamDto } from 'src/validation';
@ApiTags('Stacks')
@ApiTags(ApiTag.Stacks)
@Controller('stacks')
export class StackController {
constructor(private service: StackService) {}
@Get()
@Authenticated({ permission: Permission.StackRead })
@Endpoint({
summary: 'Retrieve stacks',
description: 'Retrieve a list of stacks.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise<StackResponseDto[]> {
return this.service.search(auth, query);
}
@Post()
@Authenticated({ permission: Permission.StackCreate })
@Endpoint({
summary: 'Create a stack',
description:
'Create a new stack by providing a name and a list of asset IDs to include in the stack. If any of the provided asset IDs are primary assets of an existing stack, the existing stack will be merged into the newly created stack.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise<StackResponseDto> {
return this.service.create(auth, dto);
}
@@ -28,18 +40,33 @@ export class StackController {
@Delete()
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete stacks',
description: 'Delete multiple stacks by providing a list of stack IDs.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.StackRead })
@Endpoint({
summary: 'Retrieve a stack',
description: 'Retrieve a specific stack by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<StackResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.StackUpdate })
@Endpoint({
summary: 'Update a stack',
description: 'Update an existing stack by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateStack(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -51,6 +78,11 @@ export class StackController {
@Delete(':id')
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a stack',
description: 'Delete a specific stack by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -58,6 +90,11 @@ export class StackController {
@Delete(':id/assets/:assetId')
@Authenticated({ permission: Permission.StackUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Remove an asset from a stack',
description: 'Remove a specific asset from a stack by providing the stack ID and asset ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeAssetFromStack(@Auth() auth: AuthDto, @Param() dto: UUIDAssetIDParamDto): Promise<void> {
return this.service.removeAsset(auth, dto);
}
+35 -2
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -12,12 +13,12 @@ import {
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { SyncService } from 'src/services/sync.service';
@ApiTags('Sync')
@ApiTags(ApiTag.Sync)
@Controller('sync')
export class SyncController {
constructor(
@@ -28,6 +29,11 @@ export class SyncController {
@Post('full-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get full sync for user',
description: 'Retrieve all assets for a full synchronization for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@@ -35,6 +41,11 @@ export class SyncController {
@Post('delta-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get delta sync for user',
description: 'Retrieve changed assets since the last sync for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@@ -43,6 +54,12 @@ export class SyncController {
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Stream sync changes',
description:
'Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
try {
await this.service.stream(auth, res, dto);
@@ -54,6 +71,11 @@ export class SyncController {
@Get('ack')
@Authenticated({ permission: Permission.SyncCheckpointRead })
@Endpoint({
summary: 'Retrieve acknowledgements',
description: 'Retrieve the synchronization acknowledgments for the current session.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getSyncAck(@Auth() auth: AuthDto): Promise<SyncAckDto[]> {
return this.service.getAcks(auth);
}
@@ -61,6 +83,12 @@ export class SyncController {
@Post('ack')
@Authenticated({ permission: Permission.SyncCheckpointUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Acknowledge changes',
description:
'Send a list of synchronization acknowledgements to confirm that the latest changes have been received.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) {
return this.service.setAcks(auth, dto);
}
@@ -68,6 +96,11 @@ export class SyncController {
@Delete('ack')
@Authenticated({ permission: Permission.SyncCheckpointDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete acknowledgements',
description: 'Delete specific synchronization acknowledgments.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto): Promise<void> {
return this.service.deleteAcks(auth, dto);
}
@@ -1,12 +1,13 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { SystemConfigService } from 'src/services/system-config.service';
@ApiTags('System Config')
@ApiTags(ApiTag.SystemConfig)
@Controller('system-config')
export class SystemConfigController {
constructor(
@@ -16,24 +17,44 @@ export class SystemConfigController {
@Get()
@Authenticated({ permission: Permission.SystemConfigRead, admin: true })
@Endpoint({
summary: 'Get system configuration',
description: 'Retrieve the current system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getConfig(): Promise<SystemConfigDto> {
return this.service.getSystemConfig();
}
@Get('defaults')
@Authenticated({ permission: Permission.SystemConfigRead, admin: true })
@Endpoint({
summary: 'Get system configuration defaults',
description: 'Retrieve the default values for the system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getConfigDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@Put()
@Authenticated({ permission: Permission.SystemConfigUpdate, admin: true })
@Endpoint({
summary: 'Update system configuration',
description: 'Update the system configuration with a new system configuration.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.service.updateSystemConfig(dto);
}
@Get('storage-template-options')
@Authenticated({ permission: Permission.SystemConfigRead, admin: true })
@Endpoint({
summary: 'Get storage template options',
description: 'Retrieve exemplary storage template options.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.storageTemplateService.getStorageTemplateOptions();
}
@@ -1,21 +1,27 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AdminOnboardingUpdateDto,
ReverseGeocodingStateResponseDto,
VersionCheckStateResponseDto,
} from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@ApiTags('System Metadata')
@ApiTags(ApiTag.SystemMetadata)
@Controller('system-metadata')
export class SystemMetadataController {
constructor(private service: SystemMetadataService) {}
@Get('admin-onboarding')
@Authenticated({ permission: Permission.SystemMetadataRead, admin: true })
@Endpoint({
summary: 'Retrieve admin onboarding',
description: 'Retrieve the current admin onboarding status.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
return this.service.getAdminOnboarding();
}
@@ -23,18 +29,33 @@ export class SystemMetadataController {
@Post('admin-onboarding')
@Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update admin onboarding',
description: 'Update the admin onboarding status.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
@Get('reverse-geocoding-state')
@Authenticated({ permission: Permission.SystemMetadataRead, admin: true })
@Endpoint({
summary: 'Retrieve reverse geocoding state',
description: 'Retrieve the current state of the reverse geocoding import.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
return this.service.getReverseGeocodingState();
}
@Get('version-check-state')
@Authenticated({ permission: Permission.SystemMetadataRead, admin: true })
@Endpoint({
summary: 'Retrieve version check state',
description: 'Retrieve the current state of the version check process.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getVersionCheckState(): Promise<VersionCheckStateResponseDto> {
return this.service.getVersionCheckState();
}
+48 -2
View File
@@ -1,5 +1,6 @@
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 { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -10,48 +11,78 @@ import {
TagUpdateDto,
TagUpsertDto,
} from 'src/dtos/tag.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Tags')
@ApiTags(ApiTag.Tags)
@Controller('tags')
export class TagController {
constructor(private service: TagService) {}
@Post()
@Authenticated({ permission: Permission.TagCreate })
@Endpoint({
summary: 'Create a tag',
description: 'Create a new tag by providing a name and optional color.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.TagRead })
@Endpoint({
summary: 'Retrieve tags',
description: 'Retrieve a list of all tags.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAllTags(@Auth() auth: AuthDto): Promise<TagResponseDto[]> {
return this.service.getAll(auth);
}
@Put()
@Authenticated({ permission: Permission.TagCreate })
@Endpoint({
summary: 'Upsert tags',
description: 'Create or update multiple tags in a single request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise<TagResponseDto[]> {
return this.service.upsert(auth, dto);
}
@Put('assets')
@Authenticated({ permission: Permission.TagAsset })
@Endpoint({
summary: 'Tag assets',
description: 'Add multiple tags to multiple assets in a single request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
return this.service.bulkTagAssets(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.TagRead })
@Endpoint({
summary: 'Retrieve a tag',
description: 'Retrieve a specific tag by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.TagUpdate })
@Endpoint({
summary: 'Update a tag',
description: 'Update an existing tag identified by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@@ -59,12 +90,22 @@ export class TagController {
@Delete(':id')
@Authenticated({ permission: Permission.TagDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a tag',
description: 'Delete a specific tag by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated({ permission: Permission.TagAsset })
@Endpoint({
summary: 'Tag assets',
description: 'Add a tag to all the specified assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
tagAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -75,6 +116,11 @@ export class TagController {
@Delete(':id/assets')
@Authenticated({ permission: Permission.TagAsset })
@Endpoint({
summary: 'Untag assets',
description: 'Remove a tag from all the specified assets.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
untagAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
+13 -2
View File
@@ -1,18 +1,24 @@
import { Controller, Get, Header, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TimelineService } from 'src/services/timeline.service';
@ApiTags('Timeline')
@ApiTags(ApiTag.Timeline)
@Controller('timeline')
export class TimelineController {
constructor(private service: TimelineService) {}
@Get('buckets')
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@Endpoint({
summary: 'Get time buckets',
description: 'Retrieve a list of all minimal time buckets.',
history: new HistoryBuilder().added('v1').internal('v1'),
})
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) {
return this.service.getTimeBuckets(auth, dto);
}
@@ -21,6 +27,11 @@ export class TimelineController {
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@ApiOkResponse({ type: TimeBucketAssetResponseDto })
@Header('Content-Type', 'application/json')
@Endpoint({
summary: 'Get time bucket',
description: 'Retrieve a string of all asset ids in a given time bucket.',
history: new HistoryBuilder().added('v1').internal('v1'),
})
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) {
return this.service.getTimeBucket(auth, dto);
}
+18 -2
View File
@@ -1,13 +1,14 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TrashService } from 'src/services/trash.service';
@ApiTags('Trash')
@ApiTags(ApiTag.Trash)
@Controller('trash')
export class TrashController {
constructor(private service: TrashService) {}
@@ -15,6 +16,11 @@ export class TrashController {
@Post('empty')
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Empty trash',
description: 'Permanently delete all items in the trash.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@@ -22,6 +28,11 @@ export class TrashController {
@Post('restore')
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Restore trash',
description: 'Restore all items in the trash.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@@ -29,6 +40,11 @@ export class TrashController {
@Post('restore/assets')
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Restore assets',
description: 'Restore specific assets from the trash.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
@@ -11,36 +12,56 @@ import {
UserAdminSearchDto,
UserAdminUpdateDto,
} from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserAdminService } from 'src/services/user-admin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Users (admin)')
@ApiTags(ApiTag.UsersAdmin)
@Controller('admin/users')
export class UserAdminController {
constructor(private service: UserAdminService) {}
@Get()
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@Endpoint({
summary: 'Search users',
description: 'Search for users.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ permission: Permission.AdminUserCreate, admin: true })
@Endpoint({
summary: 'Create a user',
description: 'Create a new user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
return this.service.create(createUserDto);
}
@Get(':id')
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@Endpoint({
summary: 'Retrieve a user',
description: 'Retrieve a specific user by their ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
@Endpoint({
summary: 'Update a user',
description: 'Update an existing user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -51,6 +72,11 @@ export class UserAdminController {
@Delete(':id')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@Endpoint({
summary: 'Delete a user',
description: 'Delete a user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -61,12 +87,22 @@ export class UserAdminController {
@Get(':id/sessions')
@Authenticated({ permission: Permission.AdminSessionRead, admin: true })
@Endpoint({
summary: 'Retrieve user sessions',
description: 'Retrieve all sessions for a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserSessionsAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SessionResponseDto[]> {
return this.service.getSessions(auth, id);
}
@Get(':id/statistics')
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@Endpoint({
summary: 'Retrieve user statistics',
description: 'Retrieve asset statistics for a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserStatisticsAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -77,12 +113,22 @@ export class UserAdminController {
@Get(':id/preferences')
@Authenticated({ permission: Permission.AdminUserRead, admin: true })
@Endpoint({
summary: 'Retrieve user preferences',
description: 'Retrieve the preferences of a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> {
return this.service.getPreferences(auth, id);
}
@Put(':id/preferences')
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
@Endpoint({
summary: 'Update user preferences',
description: 'Update the preferences of a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateUserPreferencesAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -94,6 +140,11 @@ export class UserAdminController {
@Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Restore a deleted user',
description: 'Restore a previously deleted user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.restore(auth, id);
}
+78 -2
View File
@@ -15,13 +15,14 @@ import {
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { Permission, RouteKey } from 'src/enum';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -29,7 +30,7 @@ import { UserService } from 'src/services/user.service';
import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Users')
@ApiTags(ApiTag.Users)
@Controller(RouteKey.User)
export class UserController {
constructor(
@@ -39,30 +40,55 @@ export class UserController {
@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 teh 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('me/preferences')
@Authenticated({ permission: Permission.UserPreferenceRead })
@Endpoint({
summary: 'Get my preferences',
description: 'Retrieve the preferences for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getMyPreferences(@Auth() auth: AuthDto): Promise<UserPreferencesResponseDto> {
return this.service.getMyPreferences(auth);
}
@Put('me/preferences')
@Authenticated({ permission: Permission.UserPreferenceUpdate })
@Endpoint({
summary: 'Update my preferences',
description: 'Update the preferences of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateMyPreferences(
@Auth() auth: AuthDto,
@Body() dto: UserPreferencesUpdateDto,
@@ -72,12 +98,22 @@ export class UserController {
@Get('me/license')
@Authenticated({ permission: Permission.UserLicenseRead })
@Endpoint({
summary: 'Retrieve user product key',
description: 'Retrieve information about whether the current user has a registered product key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserLicense(@Auth() auth: AuthDto): Promise<LicenseResponseDto> {
return this.service.getLicense(auth);
}
@Put('me/license')
@Authenticated({ permission: Permission.UserLicenseUpdate })
@Endpoint({
summary: 'Set user product key',
description: 'Register a product key for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(auth, license);
}
@@ -85,18 +121,33 @@ export class UserController {
@Delete('me/license')
@Authenticated({ permission: Permission.UserLicenseDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete user product key',
description: 'Delete the registered product key for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async deleteUserLicense(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteLicense(auth);
}
@Get('me/onboarding')
@Authenticated({ permission: Permission.UserOnboardingRead })
@Endpoint({
summary: 'Retrieve user onboarding',
description: 'Retrieve the onboarding status of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
return this.service.getOnboarding(auth);
}
@Put('me/onboarding')
@Authenticated({ permission: Permission.UserOnboardingUpdate })
@Endpoint({
summary: 'Update user onboarding',
description: 'Update the onboarding status of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
return this.service.setOnboarding(auth, Onboarding);
}
@@ -104,12 +155,22 @@ export class UserController {
@Delete('me/onboarding')
@Authenticated({ permission: Permission.UserOnboardingDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete user onboarding',
description: 'Delete the onboarding status of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteOnboarding(auth);
}
@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);
}
@@ -119,6 +180,11 @@ export class UserController {
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Endpoint({
summary: 'Create user profile image',
description: 'Upload and set a new profile image for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
@@ -129,6 +195,11 @@ export class UserController {
@Delete('profile-image')
@Authenticated({ permission: Permission.UserProfileImageDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete user profile image',
description: 'Delete the profile image of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@@ -136,6 +207,11 @@ export class UserController {
@Get(':id/profile-image')
@FileResponse()
@Authenticated({ permission: Permission.UserProfileImageRead })
@Endpoint({
summary: 'Retrieve user profile image',
description: 'Retrieve the profile image file for a user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
await sendFile(res, next, () => this.service.getProfileImage(id), this.logger);
}
+13 -1
View File
@@ -1,23 +1,35 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ViewService } from 'src/services/view.service';
@ApiTags('View')
@ApiTags(ApiTag.Views)
@Controller('view')
export class ViewController {
constructor(private service: ViewService) {}
@Get('folder/unique-paths')
@Authenticated()
@Endpoint({
summary: 'Retrieve unique paths',
description: 'Retrieve a list of unique folder paths from asset original paths.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise<string[]> {
return this.service.getUniqueOriginalPaths(auth);
}
@Get('folder')
@Authenticated()
@Endpoint({
summary: 'Retrieve assets by original path',
description: 'Retrieve assets that are children of a specific folder.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise<AssetResponseDto[]> {
return this.service.getAssetsByOriginalPath(auth, path);
}
@@ -0,0 +1,76 @@
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 { AuthDto } from 'src/dtos/auth.dto';
import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { WorkflowService } from 'src/services/workflow.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Workflows')
@Controller('workflows')
export class WorkflowController {
constructor(private service: WorkflowService) {}
@Post()
@Authenticated({ permission: Permission.WorkflowCreate })
@Endpoint({
summary: 'Create a workflow',
description: 'Create a new workflow, the workflow can also be created with empty filters and actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.WorkflowRead })
@Endpoint({
summary: 'List all workflows',
description: 'Retrieve a list of workflows available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.WorkflowRead })
@Endpoint({
summary: 'Retrieve a workflow',
description: 'Retrieve information about a specific workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.WorkflowUpdate })
@Endpoint({
summary: 'Update a workflow',
description:
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
updateWorkflow(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: WorkflowUpdateDto,
): Promise<WorkflowResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.WorkflowDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a workflow',
description: 'Delete a workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
-2
View File
@@ -2,8 +2,6 @@ import { StorageCore } from 'src/cores/storage.core';
import { vitest } from 'vitest';
vitest.mock('src/constants', () => ({
ADDED_IN_PREFIX: 'This property was added in ',
DEPRECATED_IN_PREFIX: 'This property was deprecated in ',
IWorker: 'IWorker',
}));
+56 -1
View File
@@ -7,6 +7,8 @@ import {
AssetVisibility,
MemoryType,
Permission,
PluginContext,
PluginTriggerType,
SharedLinkType,
SourceType,
UserAvatarColor,
@@ -14,7 +16,10 @@ import {
} from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
export type AuthUser = {
id: string;
@@ -276,6 +281,45 @@ export type AssetFace = {
updateId: string;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type PluginAction = Selectable<PluginActionTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type Workflow = Selectable<WorkflowTable> & {
triggerType: PluginTriggerType;
name: string | null;
description: string;
enabled: boolean;
};
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
workflowId: string;
filterId: string;
filterConfig: FilterConfig | null;
order: number;
};
export type WorkflowAction = Selectable<WorkflowActionTable> & {
workflowId: string;
actionId: string;
actionConfig: ActionConfig | null;
order: number;
};
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
'user2.id',
@@ -354,7 +398,7 @@ export const columns = {
'asset.stackId',
'asset.libraryId',
],
syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
@@ -416,4 +460,15 @@ export const columns = {
'asset_exif.state',
'asset_exif.timeZone',
],
plugin: [
'plugin.id as id',
'plugin.name as name',
'plugin.title as title',
'plugin.description as description',
'plugin.author as author',
'plugin.version as version',
'plugin.wasmPath as wasmPath',
'plugin.createdAt as createdAt',
'plugin.updatedAt as updatedAt',
],
} as const;
+112 -30
View File
@@ -1,8 +1,7 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { ApiCustomExtension, ApiTag, 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';
@@ -153,39 +152,122 @@ export type JobConfig = {
};
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JobConfig, config);
type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = {
addedAt?: LifecycleRelease;
deprecatedAt?: LifecycleRelease;
};
type EndpointOptions = ApiOperationOptions & { history?: HistoryBuilder };
export const Endpoint = ({ history, ...options }: EndpointOptions) => {
const decorators: MethodDecorator[] = [];
const extensions = history?.getExtensions() ?? {};
export const EndpointLifecycle = ({
addedAt,
deprecatedAt,
description,
...options
}: LifecycleMetadata & ApiOperationOptions) => {
const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })];
if (deprecatedAt) {
decorators.push(
ApiTags('Deprecated'),
ApiOperation({
deprecated: true,
description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''),
...options,
}),
);
if (!extensions[ApiCustomExtension.History]) {
console.log(`Missing history for endpoint: ${options.summary}`);
}
if (history?.isDeprecated()) {
options.deprecated = true;
decorators.push(ApiTags(ApiTag.Deprecated));
}
decorators.push(ApiOperation({ ...options, ...extensions }));
return applyDecorators(...decorators);
};
export const PropertyLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => {
const decorators: PropertyDecorator[] = [];
decorators.push(ApiProperty({ description: ADDED_IN_PREFIX + addedAt }));
if (deprecatedAt) {
decorators.push(ApiProperty({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }));
type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return applyDecorators(...decorators);
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;
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { PropertyLifecycle } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import {
@@ -48,7 +48,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
deviceId!: string;
ownerId!: string;
owner?: UserResponseDto;
@PropertyLifecycle({ deprecatedAt: 'v1.106.0' })
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1') })
libraryId?: string | null;
originalPath!: string;
originalFileName!: string;
@@ -91,7 +91,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
stack?: AssetStackResponseDto | null;
duplicateId?: string | null;
@PropertyLifecycle({ deprecatedAt: 'v1.113.0' })
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
resized?: boolean;
}
+7
View File
@@ -57,6 +57,13 @@ export class EnvDto {
@Type(() => Number)
IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true })
IMMICH_PLUGINS_ENABLED?: boolean;
@Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
IMMICH_PLUGINS_INSTALL_FOLDER?: string;
@IsInt()
@Optional()
@Type(() => Number)
+2 -94
View File
@@ -1,99 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { JobCommand, ManualJobName, QueueName } from 'src/enum';
import { ValidateBoolean, ValidateEnum } from 'src/validation';
export class JobIdParamDto {
@ValidateEnum({ enum: QueueName, name: 'JobName' })
id!: QueueName;
}
export class JobCommandDto {
@ValidateEnum({ enum: JobCommand, name: 'JobCommand' })
command!: JobCommand;
@ValidateBoolean({ optional: true })
force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit
}
import { ManualJobName } from 'src/enum';
import { ValidateEnum } from 'src/validation';
export class JobCreateDto {
@ValidateEnum({ enum: ManualJobName, name: 'ManualJobName' })
name!: ManualJobName;
}
export class JobCountsDto {
@ApiProperty({ type: 'integer' })
active!: number;
@ApiProperty({ type: 'integer' })
completed!: number;
@ApiProperty({ type: 'integer' })
failed!: number;
@ApiProperty({ type: 'integer' })
delayed!: number;
@ApiProperty({ type: 'integer' })
waiting!: number;
@ApiProperty({ type: 'integer' })
paused!: number;
}
export class QueueStatusDto {
isActive!: boolean;
isPaused!: boolean;
}
export class JobStatusDto {
@ApiProperty({ type: JobCountsDto })
jobCounts!: JobCountsDto;
@ApiProperty({ type: QueueStatusDto })
queueStatus!: QueueStatusDto;
}
export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto> {
@ApiProperty({ type: JobStatusDto })
[QueueName.ThumbnailGeneration]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.MetadataExtraction]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.VideoConversion]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.SmartSearch]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.StorageTemplateMigration]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Migration]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.BackgroundTask]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Search]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.DuplicateDetection]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.FaceDetection]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.FacialRecognition]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Sidecar]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Library]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Notification]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.BackupDatabase]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.Ocr]!: JobStatusDto;
}
+16
View File
@@ -0,0 +1,16 @@
import { MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
action!: MaintenanceAction;
}
export class MaintenanceLoginDto {
@ValidateString({ optional: true })
token?: string;
}
export class MaintenanceAuthDto {
username!: string;
}
+12 -2
View File
@@ -4,8 +4,8 @@ import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryType } from 'src/enum';
import { ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
class MemoryBaseDto {
@ValidateBoolean({ optional: true })
@@ -27,6 +27,16 @@ export class MemorySearchDto {
@ValidateBoolean({ optional: true })
isSaved?: boolean;
@IsInt()
@IsPositive()
@Type(() => Number)
@Optional()
@ApiProperty({ type: 'integer', description: 'Number of memories to return' })
size?: number;
@ValidateEnum({ enum: AssetOrderWithRandom, name: 'MemorySearchOrder', optional: true })
order?: AssetOrderWithRandom;
}
class OnThisDayDto {
+5 -5
View File
@@ -4,7 +4,7 @@ import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNeste
import { Selectable } from 'kysely';
import { DateTime } from 'luxon';
import { AssetFace, Person } from 'src/database';
import { PropertyLifecycle } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SourceType } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
@@ -111,11 +111,11 @@ export class PersonResponseDto {
birthDate!: string | null;
thumbnailPath!: string;
isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' })
@Property({ history: new HistoryBuilder().added('v1.107.0').stable('v2') })
updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
@Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') })
isFavorite?: boolean;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
@Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') })
color?: string;
}
@@ -216,7 +216,7 @@ export class PeopleResponseDto {
people!: PersonResponseDto[];
// TODO: make required after a few versions
@PropertyLifecycle({ addedAt: 'v1.110.0' })
@Property({ history: new HistoryBuilder().added('v1.110.0').stable('v2') })
hasNextPage?: boolean;
}
+110
View File
@@ -0,0 +1,110 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsSemVer,
IsString,
Matches,
ValidateNested,
} from 'class-validator';
import { PluginContext } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
class PluginManifestWasmDto {
@IsString()
@IsNotEmpty()
path!: string;
}
class PluginManifestFilterDto {
@IsString()
@IsNotEmpty()
methodName!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsArray()
@ArrayMinSize(1)
@IsEnum(PluginContext, { each: true })
supportedContexts!: PluginContext[];
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
class PluginManifestActionDto {
@IsString()
@IsNotEmpty()
methodName!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsArray()
@ArrayMinSize(1)
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true })
supportedContexts!: PluginContext[];
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
export class PluginManifestDto {
@IsString()
@IsNotEmpty()
@Matches(/^[a-z0-9-]+[a-z0-9]$/, {
message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen',
})
name!: string;
@IsString()
@IsNotEmpty()
@IsSemVer()
version!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsString()
@IsNotEmpty()
author!: string;
@ValidateNested()
@Type(() => PluginManifestWasmDto)
wasm!: PluginManifestWasmDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestFilterDto)
@IsOptional()
filters?: PluginManifestFilterDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestActionDto)
@IsOptional()
actions?: PluginManifestActionDto[];
}
+77
View File
@@ -0,0 +1,77 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
export class PluginResponseDto {
id!: string;
name!: string;
title!: string;
description!: string;
author!: string;
version!: string;
createdAt!: string;
updatedAt!: string;
filters!: PluginFilterResponseDto[];
actions!: PluginActionResponseDto[];
}
export class PluginFilterResponseDto {
id!: string;
pluginId!: string;
methodName!: string;
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}
export class PluginActionResponseDto {
id!: string;
pluginId!: string;
methodName!: string;
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}
export class PluginInstallDto {
@IsString()
@IsNotEmpty()
manifestPath!: string;
}
export type MapPlugin = {
id: string;
name: string;
title: string;
description: string;
author: string;
version: string;
wasmPath: string;
createdAt: Date;
updatedAt: Date;
filters: PluginFilter[];
actions: PluginAction[];
};
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
return {
id: plugin.id,
name: plugin.name,
title: plugin.title,
description: plugin.description,
author: plugin.author,
version: plugin.version,
createdAt: plugin.createdAt.toISOString(),
updatedAt: plugin.updatedAt.toISOString(),
filters: plugin.filters,
actions: plugin.actions,
};
}
+89
View File
@@ -0,0 +1,89 @@
import { ApiProperty } from '@nestjs/swagger';
import { QueueResponseDto, QueueStatisticsDto } from 'src/dtos/queue.dto';
import { QueueName } from 'src/enum';
export class QueueStatusLegacyDto {
isActive!: boolean;
isPaused!: boolean;
}
export class QueueResponseLegacyDto {
@ApiProperty({ type: QueueStatusLegacyDto })
queueStatus!: QueueStatusLegacyDto;
@ApiProperty({ type: QueueStatisticsDto })
jobCounts!: QueueStatisticsDto;
}
export class QueuesResponseLegacyDto implements Record<QueueName, QueueResponseLegacyDto> {
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.ThumbnailGeneration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.MetadataExtraction]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.VideoConversion]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.SmartSearch]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.StorageTemplateMigration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Migration]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.BackgroundTask]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Search]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.DuplicateDetection]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.FaceDetection]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.FacialRecognition]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Sidecar]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Library]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Notification]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.BackupDatabase]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Ocr]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Workflow]!: QueueResponseLegacyDto;
}
export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => {
return {
queueStatus: {
isPaused: response.isPaused,
isActive: response.statistics.active > 0,
},
jobCounts: response.statistics,
};
};
export const mapQueuesLegacy = (responses: QueueResponseDto[]): QueuesResponseLegacyDto => {
const legacy = new QueuesResponseLegacyDto();
for (const response of responses) {
legacy[response.name] = mapQueueLegacy(response);
}
return legacy;
};
+72
View File
@@ -0,0 +1,72 @@
import { ApiProperty } from '@nestjs/swagger';
import { HistoryBuilder, Property } from 'src/decorators';
import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum';
import { ValidateBoolean, ValidateEnum } from 'src/validation';
export class QueueNameParamDto {
@ValidateEnum({ enum: QueueName, name: 'QueueName' })
name!: QueueName;
}
export class QueueCommandDto {
@ValidateEnum({ enum: QueueCommand, name: 'QueueCommand' })
command!: QueueCommand;
@ValidateBoolean({ optional: true })
force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit
}
export class QueueUpdateDto {
@ValidateBoolean({ optional: true })
isPaused?: boolean;
}
export class QueueDeleteDto {
@ValidateBoolean({ optional: true })
@Property({
description: 'If true, will also remove failed jobs from the queue.',
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
})
failed?: boolean;
}
export class QueueJobSearchDto {
@ValidateEnum({ enum: QueueJobStatus, name: 'QueueJobStatus', optional: true, each: true })
status?: QueueJobStatus[];
}
export class QueueJobResponseDto {
id?: string;
@ValidateEnum({ enum: JobName, name: 'JobName' })
name!: JobName;
data!: object;
@ApiProperty({ type: 'integer' })
timestamp!: number;
}
export class QueueResponseDto {
@ValidateEnum({ enum: QueueName, name: 'QueueName' })
name!: QueueName;
@ValidateBoolean()
isPaused!: boolean;
statistics!: QueueStatisticsDto;
}
export class QueueStatisticsDto {
@ApiProperty({ type: 'integer' })
active!: number;
@ApiProperty({ type: 'integer' })
completed!: number;
@ApiProperty({ type: 'integer' })
failed!: number;
@ApiProperty({ type: 'integer' })
delayed!: number;
@ApiProperty({ type: 'integer' })
waiting!: number;
@ApiProperty({ type: 'integer' })
paused!: number;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { PropertyLifecycle } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
@@ -282,7 +282,7 @@ export class SearchSuggestionRequestDto {
lensModel?: string;
@ValidateBoolean({ optional: true })
@PropertyLifecycle({ addedAt: 'v111.0.0' })
@Property({ history: new HistoryBuilder().added('v1.111.0').stable('v2') })
includeNull?: boolean;
}
+1
View File
@@ -154,6 +154,7 @@ export class ServerConfigDto {
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {
+1
View File
@@ -50,6 +50,7 @@ export class AssetDeltaSyncResponseDto {
export const extraSyncModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping
return (object: Function) => {
extraSyncModels.push(object);
};
+6
View File
@@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Notification]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Workflow]!: JobSettingsDto;
}
class SystemConfigLibraryScanDto {
+9
View File
@@ -13,6 +13,12 @@ class AvatarUpdate {
class MemoriesUpdate {
@ValidateBoolean({ optional: true })
enabled?: boolean;
@Optional()
@IsInt()
@IsPositive()
@ApiProperty({ type: 'integer' })
duration?: number;
}
class RatingsUpdate {
@@ -166,6 +172,9 @@ class RatingsResponse {
class MemoriesResponse {
enabled: boolean = true;
@ApiProperty({ type: 'integer' })
duration: number = 5;
}
class FoldersResponse {
+120
View File
@@ -0,0 +1,120 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerType } from 'src/enum';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
export class WorkflowFilterItemDto {
@IsUUID()
filterId!: string;
@IsObject()
@Optional()
filterConfig?: FilterConfig;
}
export class WorkflowActionItemDto {
@IsUUID()
actionId!: string;
@IsObject()
@Optional()
actionConfig?: ActionConfig;
}
export class WorkflowCreateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true })
enabled?: boolean;
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
filters!: WorkflowFilterItemDto[];
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
actions!: WorkflowActionItemDto[];
}
export class WorkflowUpdateDto {
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true })
enabled?: boolean;
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
@Optional()
filters?: WorkflowFilterItemDto[];
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
@Optional()
actions?: WorkflowActionItemDto[];
}
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;
createdAt!: string;
enabled!: boolean;
filters!: WorkflowFilterResponseDto[];
actions!: WorkflowActionResponseDto[];
}
export class WorkflowFilterResponseDto {
id!: string;
workflowId!: string;
filterId!: string;
filterConfig!: FilterConfig | null;
order!: number;
}
export class WorkflowActionResponseDto {
id!: string;
workflowId!: string;
actionId!: string;
actionConfig!: ActionConfig | null;
order!: number;
}
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
return {
id: filter.id,
workflowId: filter.workflowId,
filterId: filter.filterId,
filterConfig: filter.filterConfig,
order: filter.order,
};
}
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
return {
id: action.id,
workflowId: action.workflowId,
actionId: action.actionId,
actionConfig: action.actionConfig,
order: action.order,
};
}
+110 -1
View File
@@ -5,6 +5,7 @@ export enum AuthType {
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
AuthType = 'immich_auth_type',
IsAuthenticated = 'immich_is_authenticated',
SharedLinkToken = 'immich_shared_link_token',
@@ -72,6 +73,14 @@ export enum MemoryType {
OnThisDay = 'on_this_day',
}
export enum AssetOrderWithRandom {
// Include existing values
Asc = AssetOrder.Asc,
Desc = AssetOrder.Desc,
/** Randomly Ordered */
Random = 'random',
}
export enum Permission {
All = 'all',
@@ -139,6 +148,8 @@ export enum Permission {
TimelineRead = 'timeline.read',
TimelineDownload = 'timeline.download',
Maintenance = 'maintenance',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
@@ -170,6 +181,11 @@ export enum Permission {
PinCodeUpdate = 'pinCode.update',
PinCodeDelete = 'pinCode.delete',
PluginCreate = 'plugin.create',
PluginRead = 'plugin.read',
PluginUpdate = 'plugin.update',
PluginDelete = 'plugin.delete',
ServerAbout = 'server.about',
ServerApkLinks = 'server.apkLinks',
ServerStorage = 'server.storage',
@@ -233,6 +249,19 @@ export enum Permission {
UserProfileImageUpdate = 'userProfileImage.update',
UserProfileImageDelete = 'userProfileImage.delete',
QueueRead = 'queue.read',
QueueUpdate = 'queue.update',
QueueJobCreate = 'queueJob.create',
QueueJobRead = 'queueJob.read',
QueueJobUpdate = 'queueJob.update',
QueueJobDelete = 'queueJob.delete',
WorkflowCreate = 'workflow.create',
WorkflowRead = 'workflow.read',
WorkflowUpdate = 'workflow.update',
WorkflowDelete = 'workflow.delete',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
@@ -268,6 +297,7 @@ export enum SystemMetadataKey {
FacialRecognitionState = 'facial-recognition-state',
MemoriesState = 'memories-state',
AdminOnboarding = 'admin-onboarding',
MaintenanceMode = 'maintenance-mode',
SystemConfig = 'system-config',
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
@@ -427,6 +457,8 @@ export enum LogLevel {
export enum ApiCustomExtension {
Permission = 'x-immich-permission',
AdminOnly = 'x-immich-admin-only',
History = 'x-immich-history',
State = 'x-immich-state',
}
export enum MetadataKey {
@@ -458,6 +490,7 @@ export enum ImmichEnvironment {
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}
@@ -516,6 +549,16 @@ export enum QueueName {
Notification = 'notifications',
BackupDatabase = 'backupDatabase',
Ocr = 'ocr',
Workflow = 'workflow',
}
export enum QueueJobStatus {
Active = 'active',
Failed = 'failed',
Complete = 'completed',
Delayed = 'delayed',
Waiting = 'waiting',
Paused = 'paused',
}
export enum JobName {
@@ -592,13 +635,20 @@ export enum JobName {
// OCR
OcrQueueAll = 'OcrQueueAll',
Ocr = 'Ocr',
// Workflow
WorkflowRun = 'WorkflowRun',
}
export enum JobCommand {
export enum QueueCommand {
Start = 'start',
/** @deprecated Use `updateQueue` instead */
Pause = 'pause',
/** @deprecated Use `updateQueue` instead */
Resume = 'resume',
/** @deprecated Use `emptyQueue` instead */
Empty = 'empty',
/** @deprecated Use `emptyQueue` instead */
ClearFailed = 'clear-failed',
}
@@ -632,6 +682,15 @@ export enum DatabaseLock {
MemoryCreation = 777,
}
export enum MaintenanceAction {
Start = 'start',
End = 'end',
}
export enum ExitCode {
AppRestart = 7,
}
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@@ -764,3 +823,53 @@ export enum CronJob {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
}
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
ApiKeys = 'API keys',
Authentication = 'Authentication',
AuthenticationAdmin = 'Authentication (admin)',
Assets = 'Assets',
Deprecated = 'Deprecated',
Download = 'Download',
Duplicates = 'Duplicates',
Faces = 'Faces',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
Map = 'Map',
Memories = 'Memories',
Notifications = 'Notifications',
NotificationsAdmin = 'Notifications (admin)',
Partners = 'Partners',
People = 'People',
Plugins = 'Plugins',
Queues = 'Queues',
Search = 'Search',
Server = 'Server',
Sessions = 'Sessions',
SharedLinks = 'Shared links',
Stacks = 'Stacks',
Sync = 'Sync',
SystemConfig = 'System config',
SystemMetadata = 'System metadata',
Tags = 'Tags',
Timeline = 'Timeline',
Trash = 'Trash',
UsersAdmin = 'Users (admin)',
Users = 'Users',
Views = 'Views',
Workflows = 'Workflows',
}
export enum PluginContext {
Asset = 'asset',
Album = 'album',
Person = 'person',
}
export enum PluginTriggerType {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
+128 -41
View File
@@ -1,61 +1,151 @@
import { Kysely } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
import { ImmichAdminModule } from 'src/app.module';
import { ImmichWorker, LogLevel } from 'src/enum';
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type DB } from 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
/**
* Manages worker lifecycle
*/
class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
let apiProcess: ChildProcess | undefined;
/**
* Fail-safe in case anything dies during restart
*/
restarting = false;
const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
};
/**
* Boot all enabled workers
*/
async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
const onExit = (name: string, exitCode: number | null) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (apiProcess && name !== ImmichWorker.Api) {
console.error('Killing api process');
apiProcess.kill('SIGTERM');
apiProcess = undefined;
if (isMaintenanceMode) {
this.startWorker(ImmichWorker.Maintenance);
} else {
for (const worker of workers) {
this.startWorker(worker);
}
}
}
process.exit(exitCode);
};
/**
* Initialise a short-lived Nest application to build configuration
* @returns System configuration
*/
private async isMaintenanceMode(): Promise<boolean> {
const { database } = new ConfigRepository().getEnv();
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
const systemMetadataRepository = new SystemMetadataRepository(kysely);
function bootstrapWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false;
} catch (error) {
// Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') {
return false;
}
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let worker: Worker | ChildProcess;
if (name === ImmichWorker.Api) {
worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
apiProcess = worker;
} else {
worker = new Worker(workerFile);
throw error;
} finally {
await kysely.destroy();
}
}
worker.on('error', (error) => onError(name, error));
worker.on('exit', (exitCode) => onExit(name, exitCode));
/**
* Start an individual worker
* @param name 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) {
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
void this.bootstrap();
this.restarting = false;
}
return;
}
// shutdown the entire process
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 bootstrap() {
function main() {
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
if (immichApp === 'immich-admin') {
process.title = 'immich_admin_cli';
process.env.IMMICH_LOG_LEVEL = LogLevel.Warn;
return CommandFactory.run(ImmichAdminModule);
}
@@ -72,10 +162,7 @@ function bootstrap() {
}
process.title = 'immich';
const { workers } = new ConfigRepository().getEnv();
for (const worker of workers) {
bootstrapWorker(worker);
}
void new Workers().bootstrap();
}
void bootstrap();
void main();
@@ -0,0 +1,58 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { MetadataKey } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const MaintenanceRoute = (options = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
return applyDecorators(...decorators);
};
export interface MaintenanceAuthRequest extends Request {
auth?: MaintenanceAuthDto;
}
export interface MaintenanceAuthenticatedRequest extends Request {
auth: MaintenanceAuthDto;
}
export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => {
return context.switchToHttp().getRequest<MaintenanceAuthenticatedRequest>().auth;
});
@Injectable()
export class MaintenanceAuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private service: MaintenanceWorkerService,
) {
this.logger.setContext(MaintenanceAuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute,
targets,
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers);
return true;
}
}
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
AppRestartV1: [AppRestartEvent];
}
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
@Injectable()
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
@WebSocketServer()
private websocketServer?: Server;
constructor(
private logger: LoggingRepository,
private appRepository: AppRepository,
) {
this.logger.setContext(MaintenanceWebsocketRepository.name);
}
afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.websocketServer?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.websocketServer?.serverSideEmit(event, ...args);
}
handleConnection(client: Socket) {
this.logger.log(`Websocket Connect: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
}
}
@@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, MaintenanceAction } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
@Get('server/config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
@Post('admin/maintenance/login')
async maintenanceLogin(
@Req() request: Request,
@Body() dto: MaintenanceLoginDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<MaintenanceAuthDto> {
const token = dto.token ?? request.cookies[ImmichCookie.MaintenanceToken];
const auth = await this.service.login(token);
return respondWithCookie(res, auth, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: token }],
});
}
@Post('admin/maintenance')
@MaintenanceRoute()
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (dto.action === MaintenanceAction.End) {
await this.service.endMaintenance();
}
}
}
@@ -0,0 +1,128 @@
import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { automock, getMocks, ServiceMocks } from 'test/utils';
describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService;
let mocks: ServiceMocks;
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
beforeEach(() => {
mocks = getMocks();
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
sut = new MaintenanceWorkerService(
mocks.logger as never,
mocks.app,
mocks.config,
mocks.systemMetadata as never,
maintenanceWorkerRepositoryMock,
);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getSystemConfig', () => {
it('should respond the server is in maintenance mode', async () => {
await expect(sut.getSystemConfig()).resolves.toMatchObject(
expect.objectContaining({
maintenanceMode: true,
}),
);
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('logSecret', () => {
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should log a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.logSecret()).resolves.toBeUndefined();
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
const [url] = mocks.logger.log.mock.lastCall!;
const token = RE_LOGIN_URL.exec(url)![1];
await expect(sut.login(token)).resolves.toEqual(
expect.objectContaining({
username: 'immich-admin',
}),
);
});
});
describe('authenticate', () => {
it('should fail without a cookie', async () => {
await expect(sut.authenticate({})).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should parse cookie properly', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.authenticate({
cookie: 'immich_maintenance_token=invalid-jwt',
}),
).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
});
describe('login', () => {
it('should fail without token', async () => {
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should fail with expired JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('0s')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
it('should succeed with valid JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({ _mockValue: true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).resolves.toEqual(
expect.objectContaining({
_mockValue: true,
}),
);
});
});
describe('endMaintenance', () => {
it('should set maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.endMaintenance()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: false,
});
});
});
});
@@ -0,0 +1,161 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type ApiService as _ApiService } from 'src/services/api.service';
import { type BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceWorkerService {
constructor(
protected logger: LoggingRepository,
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
) {
this.logger.setContext(this.constructor.name);
}
/**
* {@link _BaseService.configRepos}
*/
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
async getSystemConfig() {
const config = await this.getConfig({ withCache: false });
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized: true,
isOnboarded: true,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: true,
};
}
/**
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
let index = '';
try {
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
request.method.toLowerCase() !== 'get' ||
excludePaths.some((item) => request.url.startsWith(item))
) {
return next();
}
const maintenancePath = '/maintenance';
if (!request.url.startsWith(maintenancePath)) {
const params = new URLSearchParams();
params.set('continue', request.path);
return res.redirect(`${maintenancePath}?${params}`);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}
private async secret(): Promise<string> {
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
secret: string;
};
return state.secret;
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const url = await createMaintenanceLoginUrl(
baseUrl,
{
username: 'immich-admin',
},
await this.secret(),
);
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
}
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) {
throw new UnauthorizedException('Missing JWT Token');
}
const secret = await this.secret();
try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
return result.payload;
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
}
async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
description: string;
context: PluginContext;
schema: JSONSchema | null;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: PluginTriggerType.AssetCreate,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
schema: {
type: 'object',
properties: {
assetType: {
type: 'string',
description: 'Type of the asset',
default: 'ALL',
enum: ['Image', 'Video', 'All'],
},
},
},
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected in an asset',
context: PluginContext.Person,
schema: null,
},
];
+21 -12
View File
@@ -25,8 +25,8 @@ select
"album"."id"
from
"album"
left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."usersId"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@@ -52,8 +52,8 @@ select
"album"."id"
from
"album"
left join "album_user" on "album_user"."albumsId" = "album"."id"
left join "user" on "user"."id" = "album_user"."usersId"
left join "album_user" on "album_user"."albumId" = "album"."id"
left join "user" on "user"."id" = "album_user"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@@ -81,11 +81,11 @@ select
"asset"."livePhotoVideoId"
from
"album"
inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumsId"
inner join "asset" on "asset"."id" = "albumAssets"."assetsId"
inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumId"
inner join "asset" on "asset"."id" = "albumAssets"."assetId"
and "asset"."deletedAt" is null
left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."usersId"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
cross join "target"
where
@@ -136,11 +136,11 @@ from
"shared_link"
left join "album" on "album"."id" = "shared_link"."albumId"
and "album"."deletedAt" is null
left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id"
left join "asset" on "asset"."id" = "shared_link_asset"."assetsId"
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
left join "asset" on "asset"."id" = "shared_link_asset"."assetId"
and "asset"."deletedAt" is null
left join "album_asset" on "album_asset"."albumsId" = "album"."id"
left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetsId"
left join "album_asset" on "album_asset"."albumId" = "album"."id"
left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetId"
and "albumAssets"."deletedAt" is null
where
"shared_link"."id" = $1
@@ -243,3 +243,12 @@ from
where
"partner"."sharedById" in ($1)
and "partner"."sharedWithId" = $2
-- AccessRepository.workflow.checkOwnerAccess
select
"workflow"."id"
from
"workflow"
where
"workflow"."id" in ($1)
and "workflow"."ownerId" = $2
+29 -29
View File
@@ -43,13 +43,13 @@ select
from
"user"
where
"user"."id" = "album_user"."usersId"
"user"."id" = "album_user"."userId"
) as obj
) as "user"
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
) as agg
) as "albumUsers",
(
@@ -76,9 +76,9 @@ select
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumsId" = "album"."id"
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
@@ -134,18 +134,18 @@ select
from
"user"
where
"user"."id" = "album_user"."usersId"
"user"."id" = "album_user"."userId"
) as obj
) as "user"
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
) as agg
) as "albumUsers"
from
"album"
inner join "album_asset" on "album_asset"."albumsId" = "album"."id"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
@@ -154,11 +154,11 @@ where
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
and "album_user"."usersId" = $2
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
)
and "album_asset"."assetsId" = $3
and "album_asset"."assetId" = $3
and "album"."deletedAt" is null
order by
"album"."createdAt" desc,
@@ -166,7 +166,7 @@ order by
-- AlbumRepository.getMetadataForIds
select
"album_asset"."albumsId" as "albumId",
"album_asset"."albumId" as "albumId",
min(
("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date
) as "startDate",
@@ -177,13 +177,13 @@ select
count("asset"."id")::int as "assetCount"
from
"asset"
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"asset"."visibility" in ('archive', 'timeline')
and "album_asset"."albumsId" in ($1)
and "album_asset"."albumId" in ($1)
and "asset"."deletedAt" is null
group by
"album_asset"."albumsId"
"album_asset"."albumId"
-- AlbumRepository.getOwned
select
@@ -228,13 +228,13 @@ select
from
"user"
where
"user"."id" = "album_user"."usersId"
"user"."id" = "album_user"."userId"
) as obj
) as "user"
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
) as agg
) as "albumUsers",
(
@@ -283,13 +283,13 @@ select
from
"user"
where
"user"."id" = "album_user"."usersId"
"user"."id" = "album_user"."userId"
) as obj
) as "user"
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
) as agg
) as "albumUsers",
(
@@ -332,10 +332,10 @@ where
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
and (
"album"."ownerId" = $1
or "album_user"."usersId" = $2
or "album_user"."userId" = $2
)
)
or exists (
@@ -382,7 +382,7 @@ where
from
"album_user"
where
"album_user"."albumsId" = "album"."id"
"album_user"."albumId" = "album"."id"
)
and not exists (
select
@@ -397,7 +397,7 @@ order by
-- AlbumRepository.removeAssetsFromAll
delete from "album_asset"
where
"album_asset"."assetsId" in ($1)
"album_asset"."assetId" in ($1)
-- AlbumRepository.getAssetIds
select
@@ -405,8 +405,8 @@ select
from
"album_asset"
where
"album_asset"."albumsId" = $1
and "album_asset"."assetsId" in ($2)
"album_asset"."albumId" = $1
and "album_asset"."assetId" in ($2)
-- AlbumRepository.getContributorCounts
select
@@ -414,10 +414,10 @@ select
count(*) as "assetCount"
from
"album_asset"
inner join "asset" on "asset"."id" = "assetsId"
inner join "asset" on "asset"."id" = "assetId"
where
"asset"."deletedAt" is null
and "album_asset"."albumsId" = $1
and "album_asset"."albumId" = $1
group by
"asset"."ownerId"
order by
@@ -427,10 +427,10 @@ order by
insert into
"album_asset"
select
"album_asset"."albumsId",
$1 as "assetsId"
"album_asset"."albumId",
$1 as "assetId"
from
"album_asset"
where
"album_asset"."assetsId" = $2
"album_asset"."assetId" = $2
on conflict do nothing
+7 -7
View File
@@ -2,12 +2,12 @@
-- AlbumUserRepository.create
insert into
"album_user" ("usersId", "albumsId")
"album_user" ("userId", "albumId")
values
($1, $2)
returning
"usersId",
"albumsId",
"userId",
"albumId",
"role"
-- AlbumUserRepository.update
@@ -15,13 +15,13 @@ update "album_user"
set
"role" = $1
where
"usersId" = $2
and "albumsId" = $3
"userId" = $2
and "albumId" = $3
returning
*
-- AlbumUserRepository.delete
delete from "album_user"
where
"usersId" = $1
and "albumsId" = $2
"userId" = $1
and "albumId" = $2
+2 -2
View File
@@ -46,9 +46,9 @@ select
"tag"."value"
from
"tag"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
where
"asset"."id" = "tag_asset"."assetsId"
"asset"."id" = "tag_asset"."assetId"
) as agg
) as "tags"
from
+7 -7
View File
@@ -64,7 +64,7 @@ with
from
asset
),
date_part('year', current_date)::int - 1
$3
) as "year"
)
select
@@ -81,21 +81,21 @@ with
where
"asset_job_status"."previewAt" is not null
and (asset."localDateTime" at time zone 'UTC')::date = today.date
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."visibility" = $4
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."visibility" = $5
and exists (
select
from
"asset_file"
where
"assetId" = "asset"."id"
and "asset_file"."type" = $5
and "asset_file"."type" = $6
)
and "asset"."deletedAt" is null
order by
(asset."localDateTime" at time zone 'UTC')::date desc
limit
$6
$7
) as "a" on true
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
)
@@ -160,9 +160,9 @@ select
"tag"."parentId"
from
"tag"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
where
"asset"."id" = "tag_asset"."assetsId"
"asset"."id" = "tag_asset"."assetId"
) as agg
) as "tags",
to_json("asset_exif") as "exifInfo"
+2 -2
View File
@@ -23,8 +23,8 @@ where
from
"album_asset"
where
"asset"."id" = "album_asset"."assetsId"
and "album_asset"."albumsId" in ($3)
"asset"."id" = "album_asset"."assetId"
and "album_asset"."albumId" in ($3)
)
)
order by
+7 -7
View File
@@ -37,7 +37,7 @@ select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
@@ -66,7 +66,7 @@ select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
@@ -104,7 +104,7 @@ select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
@@ -137,7 +137,7 @@ select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
@@ -159,15 +159,15 @@ where
-- MemoryRepository.getAssetIds
select
"assetsId"
"assetId"
from
"memory_asset"
where
"memoriesId" = $1
and "assetsId" in ($2)
and "assetId" in ($2)
-- MemoryRepository.addAssetIds
insert into
"memory_asset" ("memoriesId", "assetsId")
"memory_asset" ("memoriesId", "assetId")
values
($1, $2)
+159
View File
@@ -0,0 +1,159 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PluginRepository.getPlugin
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
where
"plugin"."id" = $1
-- PluginRepository.getPluginByName
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
where
"plugin"."name" = $1
-- PluginRepository.getAllPlugins
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
order by
"plugin"."name"
-- PluginRepository.getFilter
select
*
from
"plugin_filter"
where
"id" = $1
-- PluginRepository.getFiltersByPlugin
select
*
from
"plugin_filter"
where
"pluginId" = $1
-- PluginRepository.getAction
select
*
from
"plugin_action"
where
"id" = $1
-- PluginRepository.getActionsByPlugin
select
*
from
"plugin_action"
where
"pluginId" = $1
@@ -4,10 +4,10 @@
insert into
"shared_link_asset"
select
$1 as "assetsId",
"shared_link_asset"."sharedLinksId"
$1 as "assetId",
"shared_link_asset"."sharedLinkId"
from
"shared_link_asset"
where
"shared_link_asset"."assetsId" = $2
"shared_link_asset"."assetId" = $2
on conflict do nothing
@@ -19,7 +19,7 @@ from
to_json("exifInfo") as "exifInfo"
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetsId"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
inner join lateral (
select
"asset_exif".*
@@ -29,7 +29,7 @@ from
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"shared_link"."id" = "shared_link_asset"."sharedLinksId"
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
@@ -51,7 +51,7 @@ from
to_json("owner") as "owner"
from
"album"
left join "album_asset" on "album_asset"."albumsId" = "album"."id"
left join "album_asset" on "album_asset"."albumId" = "album"."id"
left join lateral (
select
"asset".*,
@@ -67,7 +67,7 @@ from
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"album_asset"."assetsId" = "asset"."id"
"album_asset"."assetId" = "asset"."id"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
@@ -108,14 +108,14 @@ select distinct
to_json("album") as "album"
from
"shared_link"
left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id"
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
left join lateral (
select
json_agg("asset") as "assets"
from
"asset"
where
"asset"."id" = "shared_link_asset"."assetsId"
"asset"."id" = "shared_link_asset"."assetId"
and "asset"."deletedAt" is null
) as "assets" on true
left join lateral (
+2 -2
View File
@@ -89,9 +89,9 @@ select
"tag"."parentId"
from
"tag"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
where
"tag_asset"."assetsId" = "asset"."id"
"tag_asset"."assetId" = "asset"."id"
) as agg
) as "tags",
to_json("exifInfo") as "exifInfo"
+45 -45
View File
@@ -2,12 +2,12 @@
-- SyncRepository.album.getCreatedAfter
select
"albumsId" as "id",
"albumId" as "id",
"createId"
from
"album_user"
where
"usersId" = $1
"userId" = $1
and "createId" >= $2
and "createId" < $3
order by
@@ -40,13 +40,13 @@ select distinct
"album"."updateId"
from
"album" as "album"
left join "album_user" as "album_users" on "album"."id" = "album_users"."albumsId"
left join "album_user" as "album_users" on "album"."id" = "album_users"."albumId"
where
"album"."updateId" < $1
and "album"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_users"."usersId" = $4
or "album_users"."userId" = $4
)
order by
"album"."updateId" asc
@@ -72,12 +72,12 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetsId"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."albumsId" = $4
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -102,16 +102,16 @@ select
"asset"."updateId"
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumsId"
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."usersId" = $5
or "album_user"."userId" = $5
)
order by
"asset"."updateId" asc
@@ -137,15 +137,15 @@ select
"asset"."libraryId"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetsId"
inner join "album" on "album"."id" = "album_asset"."albumsId"
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."usersId" = $4
or "album_user"."userId" = $4
)
order by
"album_asset"."updateId" asc
@@ -180,12 +180,12 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId"
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."albumsId" = $4
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -219,16 +219,16 @@ select
"asset_exif"."updateId"
from
"asset_exif" as "asset_exif"
inner join "album_asset" on "album_asset"."assetsId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumsId"
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."usersId" = $5
or "album_user"."userId" = $5
)
order by
"asset_exif"."updateId" asc
@@ -263,23 +263,23 @@ select
"asset_exif"."fps"
from
"album_asset" as "album_asset"
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId"
inner join "album" on "album"."id" = "album_asset"."albumsId"
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."usersId" = $4
or "album_user"."userId" = $4
)
order by
"album_asset"."updateId" asc
-- SyncRepository.albumToAsset.getBackfill
select
"album_asset"."assetsId" as "assetId",
"album_asset"."albumsId" as "albumId",
"album_asset"."assetId" as "assetId",
"album_asset"."albumId" as "albumId",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
@@ -287,7 +287,7 @@ where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."albumsId" = $4
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -311,11 +311,11 @@ where
union
(
select
"album_user"."albumsId" as "id"
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."usersId" = $4
"album_user"."userId" = $4
)
)
order by
@@ -323,27 +323,27 @@ order by
-- SyncRepository.albumToAsset.getUpserts
select
"album_asset"."assetsId" as "assetId",
"album_asset"."albumsId" as "albumId",
"album_asset"."assetId" as "assetId",
"album_asset"."albumId" as "albumId",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "album" on "album"."id" = "album_asset"."albumsId"
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."usersId" = $4
or "album_user"."userId" = $4
)
order by
"album_asset"."updateId" asc
-- SyncRepository.albumUser.getBackfill
select
"album_user"."albumsId" as "albumId",
"album_user"."usersId" as "userId",
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."updateId"
from
@@ -352,7 +352,7 @@ where
"album_user"."updateId" < $1
and "album_user"."updateId" <= $2
and "album_user"."updateId" >= $3
and "albumsId" = $4
and "albumId" = $4
order by
"album_user"."updateId" asc
@@ -376,11 +376,11 @@ where
union
(
select
"album_user"."albumsId" as "id"
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."usersId" = $4
"album_user"."userId" = $4
)
)
order by
@@ -388,8 +388,8 @@ order by
-- SyncRepository.albumUser.getUpserts
select
"album_user"."albumsId" as "albumId",
"album_user"."usersId" as "userId",
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."updateId"
from
@@ -397,7 +397,7 @@ from
where
"album_user"."updateId" < $1
and "album_user"."updateId" > $2
and "album_user"."albumsId" in (
and "album_user"."albumId" in (
select
"id"
from
@@ -407,11 +407,11 @@ where
union
(
select
"albumUsers"."albumsId" as "id"
"albumUsers"."albumId" as "id"
from
"album_user" as "albumUsers"
where
"albumUsers"."usersId" = $4
"albumUsers"."userId" = $4
)
)
order by
@@ -656,7 +656,7 @@ order by
-- SyncRepository.memoryToAsset.getUpserts
select
"memoriesId" as "memoryId",
"assetsId" as "assetId",
"assetId" as "assetId",
"updateId"
from
"memory_asset" as "memory_asset"
+6 -6
View File
@@ -84,19 +84,19 @@ where
-- TagRepository.addAssetIds
insert into
"tag_asset" ("tagsId", "assetsId")
"tag_asset" ("tagId", "assetId")
values
($1, $2)
-- TagRepository.removeAssetIds
delete from "tag_asset"
where
"tagsId" = $1
and "assetsId" in ($2)
"tagId" = $1
and "assetId" in ($2)
-- TagRepository.upsertAssetIds
insert into
"tag_asset" ("assetId", "tagsIds")
"tag_asset" ("assetId", "tagIds")
values
($1, $2)
on conflict do nothing
@@ -107,9 +107,9 @@ returning
begin
delete from "tag_asset"
where
"assetsId" = $1
"assetId" = $1
insert into
"tag_asset" ("tagsId", "assetsId")
"tag_asset" ("tagId", "assetId")
values
($1, $2)
on conflict do nothing
@@ -0,0 +1,68 @@
-- NOTE: This file is auto generated by ./sql-generator
-- WorkflowRepository.getWorkflow
select
*
from
"workflow"
where
"id" = $1
-- WorkflowRepository.getWorkflowsByOwner
select
*
from
"workflow"
where
"ownerId" = $1
order by
"name"
-- WorkflowRepository.getWorkflowsByTrigger
select
*
from
"workflow"
where
"triggerType" = $1
and "enabled" = $2
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
select
*
from
"workflow"
where
"ownerId" = $1
and "triggerType" = $2
and "enabled" = $3
-- WorkflowRepository.deleteWorkflow
delete from "workflow"
where
"id" = $1
-- WorkflowRepository.getFilters
select
*
from
"workflow_filter"
where
"workflowId" = $1
order by
"order" asc
-- WorkflowRepository.deleteFiltersByWorkflow
delete from "workflow_filter"
where
"workflowId" = $1
-- WorkflowRepository.getActions
select
*
from
"workflow_action"
where
"workflowId" = $1
order by
"order" asc
+34 -12
View File
@@ -52,8 +52,8 @@ class ActivityAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null))
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.isActivityEnabled', '=', true)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
@@ -96,8 +96,8 @@ class AlbumAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user', 'album_user.albumsId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.usersId').on('user.deletedAt', 'is', null))
.leftJoin('album_user', 'album_user.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.deletedAt', 'is', null)
.where('user.id', '=', userId)
@@ -138,12 +138,12 @@ class AssetAccess {
return this.db
.with('target', (qb) => qb.selectNoFrom(sql`array[${sql.join([...assetIds])}]::uuid[]`.as('ids')))
.selectFrom('album')
.innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumsId')
.innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumId')
.innerJoin('asset', (join) =>
join.onRef('asset.id', '=', 'albumAssets.assetsId').on('asset.deletedAt', 'is', null),
join.onRef('asset.id', '=', 'albumAssets.assetId').on('asset.deletedAt', 'is', null),
)
.leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null))
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.crossJoin('target')
.select(['asset.id', 'asset.livePhotoVideoId'])
.where((eb) =>
@@ -223,13 +223,13 @@ class AssetAccess {
return this.db
.selectFrom('shared_link')
.leftJoin('album', (join) => join.onRef('album.id', '=', 'shared_link.albumId').on('album.deletedAt', 'is', null))
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id')
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoin('asset', (join) =>
join.onRef('asset.id', '=', 'shared_link_asset.assetsId').on('asset.deletedAt', 'is', null),
join.onRef('asset.id', '=', 'shared_link_asset.assetId').on('asset.deletedAt', 'is', null),
)
.leftJoin('album_asset', 'album_asset.albumsId', 'album.id')
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
.leftJoin('asset as albumAssets', (join) =>
join.onRef('albumAssets.id', '=', 'album_asset.assetsId').on('albumAssets.deletedAt', 'is', null),
join.onRef('albumAssets.id', '=', 'album_asset.assetId').on('albumAssets.deletedAt', 'is', null),
)
.select([
'asset.id as assetId',
@@ -462,6 +462,26 @@ class TagAccess {
}
}
class WorkflowAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, workflowIds: Set<string>) {
if (workflowIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('workflow')
.select('workflow.id')
.where('workflow.id', 'in', [...workflowIds])
.where('workflow.ownerId', '=', userId)
.execute()
.then((workflows) => new Set(workflows.map((workflow) => workflow.id)));
}
}
@Injectable()
export class AccessRepository {
activity: ActivityAccess;
@@ -476,6 +496,7 @@ export class AccessRepository {
stack: StackAccess;
tag: TagAccess;
timeline: TimelineAccess;
workflow: WorkflowAccess;
constructor(@InjectKysely() db: Kysely<DB>) {
this.activity = new ActivityAccess(db);
@@ -490,5 +511,6 @@ export class AccessRepository {
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db);
this.workflow = new WorkflowAccess(db);
}
}
@@ -7,36 +7,36 @@ import { DB } from 'src/schema';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
export type AlbumPermissionId = {
albumsId: string;
usersId: string;
albumId: string;
userId: string;
};
@Injectable()
export class AlbumUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
create(albumUser: Insertable<AlbumUserTable>) {
return this.db
.insertInto('album_user')
.values(albumUser)
.returning(['usersId', 'albumsId', 'role'])
.returning(['userId', 'albumId', 'role'])
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
update({ usersId, albumsId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
update({ userId, albumId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
return this.db
.updateTable('album_user')
.set(dto)
.where('usersId', '=', usersId)
.where('albumsId', '=', albumsId)
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.returningAll()
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
async delete({ usersId, albumsId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('usersId', '=', usersId).where('albumsId', '=', albumsId).execute();
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
}
}
+32 -32
View File
@@ -33,11 +33,11 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
.selectFrom('album_user')
.select('album_user.role')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.usersId'))
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
.$notNull()
.as('user'),
)
.whereRef('album_user.albumsId', '=', 'album.id'),
.whereRef('album_user.albumId', '=', 'album.id'),
)
.$notNull()
.as('albumUsers');
@@ -57,8 +57,8 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.selectAll('asset')
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
.whereRef('album_asset.albumsId', '=', 'album.id')
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.whereRef('album_asset.albumId', '=', 'album.id')
.where('asset.deletedAt', 'is', null)
.$call(withDefaultVisibility)
.orderBy('asset.fileCreatedAt', 'desc')
@@ -92,19 +92,19 @@ export class AlbumRepository {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_asset', 'album_asset.albumsId', 'album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumsId', '=', 'album.id')
.where('album_user.usersId', '=', ownerId),
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
)
.where('album_asset.assetsId', '=', assetId)
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.select(withOwner)
@@ -125,16 +125,16 @@ export class AlbumRepository {
this.db
.selectFrom('asset')
.$call(withDefaultVisibility)
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
.select('album_asset.albumsId as albumId')
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.select('album_asset.albumId as albumId')
.select((eb) => eb.fn.min(sql<Date>`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate'))
.select((eb) => eb.fn.max(sql<Date>`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('endDate'))
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
.select((eb) => eb.fn.max('asset.updatedAt').as('lastModifiedAssetTimestamp'))
.select((eb) => sql<number>`${eb.fn.count('asset.id')}::int`.as('assetCount'))
.where('album_asset.albumsId', 'in', ids)
.where('album_asset.albumId', 'in', ids)
.where('asset.deletedAt', 'is', null)
.groupBy('album_asset.albumsId')
.groupBy('album_asset.albumId')
.execute()
);
}
@@ -166,8 +166,8 @@ export class AlbumRepository {
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumsId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.usersId', '=', ownerId)])),
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
),
eb.exists(
eb
@@ -195,7 +195,7 @@ export class AlbumRepository {
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumsId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
.orderBy('album.createdAt', 'desc')
@@ -217,7 +217,7 @@ export class AlbumRepository {
@GenerateSql({ params: [[DummyValue.UUID]] })
@Chunked()
async removeAssetsFromAll(assetIds: string[]): Promise<void> {
await this.db.deleteFrom('album_asset').where('album_asset.assetsId', 'in', assetIds).execute();
await this.db.deleteFrom('album_asset').where('album_asset.assetId', 'in', assetIds).execute();
}
@Chunked({ paramIndex: 1 })
@@ -228,8 +228,8 @@ export class AlbumRepository {
await this.db
.deleteFrom('album_asset')
.where('album_asset.albumsId', '=', albumId)
.where('album_asset.assetsId', 'in', assetIds)
.where('album_asset.albumId', '=', albumId)
.where('album_asset.assetId', 'in', assetIds)
.execute();
}
@@ -250,10 +250,10 @@ export class AlbumRepository {
return this.db
.selectFrom('album_asset')
.selectAll()
.where('album_asset.albumsId', '=', albumId)
.where('album_asset.assetsId', 'in', assetIds)
.where('album_asset.albumId', '=', albumId)
.where('album_asset.assetId', 'in', assetIds)
.execute()
.then((results) => new Set(results.map(({ assetsId }) => assetsId)));
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
@@ -276,7 +276,7 @@ export class AlbumRepository {
await tx
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumsId: newAlbum.id, usersId: albumUser.userId, role: albumUser.role })),
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
)
.execute();
}
@@ -317,12 +317,12 @@ export class AlbumRepository {
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise<void> {
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
if (values.length === 0) {
return;
}
@@ -344,7 +344,7 @@ export class AlbumRepository {
.updateTable('album')
.set((eb) => ({
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_asset.assetsId')
.select('album_asset.assetId')
.orderBy('asset.fileCreatedAt', 'desc')
.limit(1),
}))
@@ -360,7 +360,7 @@ export class AlbumRepository {
eb.exists(
this.updateThumbnailBuilder(eb)
.select(sql`1`.as('1'))
.whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetsId'), // Has invalid assets
.whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetId'), // Has invalid assets
),
),
]),
@@ -375,9 +375,9 @@ export class AlbumRepository {
return eb
.selectFrom('album_asset')
.innerJoin('asset', (join) =>
join.onRef('album_asset.assetsId', '=', 'asset.id').on('asset.deletedAt', 'is', null),
join.onRef('album_asset.assetId', '=', 'asset.id').on('asset.deletedAt', 'is', null),
)
.whereRef('album_asset.albumsId', '=', 'album.id');
.whereRef('album_asset.albumId', '=', 'album.id');
}
/**
@@ -388,9 +388,9 @@ export class AlbumRepository {
getContributorCounts(id: string) {
return this.db
.selectFrom('album_asset')
.innerJoin('asset', 'asset.id', 'assetsId')
.innerJoin('asset', 'asset.id', 'assetId')
.where('asset.deletedAt', 'is', sql.lit(null))
.where('album_asset.albumsId', '=', id)
.where('album_asset.albumId', '=', id)
.select('asset.ownerId as userId')
.select((eb) => eb.fn.countAll<number>().as('assetCount'))
.groupBy('asset.ownerId')
@@ -405,8 +405,8 @@ export class AlbumRepository {
.expression((eb) =>
eb
.selectFrom('album_asset')
.select((eb) => ['album_asset.albumsId', eb.val(targetAssetId).as('assetsId')])
.where('album_asset.assetsId', '=', sourceAssetId),
.select((eb) => ['album_asset.albumId', eb.val(targetAssetId).as('assetId')])
.where('album_asset.assetId', '=', sourceAssetId),
)
.onConflict((oc) => oc.doNothing())
.execute();
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class AppRepository {
private closeFn?: () => Promise<void>;
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));
// in exceptional circumstance, the application may hang
setTimeout(() => process.exit(ExitCode.AppRestart), 2000);
/* eslint-enable unicorn/no-process-exit */
}
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
}
@@ -47,8 +47,8 @@ export class AssetJobRepository {
eb
.selectFrom('tag')
.select(['tag.value'])
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('asset.id', '=', 'tag_asset.assetId'),
).as('tags'),
)
.limit(1)
+9 -8
View File
@@ -73,9 +73,10 @@ export interface TimeBucketItem {
count: number;
}
export interface MonthDay {
export interface YearMonthDay {
day: number;
month: number;
year: number;
}
interface AssetExploreFieldOptions {
@@ -259,8 +260,8 @@ export class AssetRepository {
return this.db.insertInto('asset').values(assets).returningAll().execute();
}
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) {
@GenerateSql({ params: [DummyValue.UUID, { year: 2000, day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { year, day, month }: YearMonthDay) {
return this.db
.with('res', (qb) =>
qb
@@ -270,7 +271,7 @@ export class AssetRepository {
eb
.fn('generate_series', [
sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`,
sql`date_part('year', current_date)::int - 1`,
sql`${year - 1}`,
])
.as('year'),
)
@@ -563,8 +564,8 @@ export class AssetRepository {
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.albumId, (qb) =>
qb
.innerJoin('album_asset', 'asset.id', 'album_asset.assetsId')
.where('album_asset.albumsId', '=', asUuid(options.albumId!)),
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.withStacked, (qb) =>
@@ -641,8 +642,8 @@ export class AssetRepository {
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetsId', '=', 'asset.id')
.where('album_asset.albumsId', '=', asUuid(options.albumId!)),
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
),
),
)
@@ -257,7 +257,7 @@ describe('getEnv', () => {
expect(telemetry).toEqual({
apiPort: 8081,
microservicesPort: 8082,
metrics: new Set([]),
metrics: new Set(),
});
});

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