mirror of
https://github.com/immich-app/immich.git
synced 2026-02-12 20:08:25 +03:00
strip down core
This commit is contained in:
@@ -1,87 +0,0 @@
|
||||
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}] `);
|
||||
}
|
||||
@@ -5,35 +5,22 @@ import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { KyselyModule } from 'nestjs-kysely';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
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 { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
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 { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.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 { QueueService } from 'src/services/queue.service';
|
||||
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const common = [...repositories, ...services, GlobalExceptionFilter];
|
||||
@@ -45,7 +32,7 @@ const commonMiddleware = [
|
||||
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
||||
];
|
||||
|
||||
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
||||
const apiMiddleware = [...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
||||
|
||||
const configRepository = new ConfigRepository();
|
||||
const { bull, cls, database, otel } = configRepository.getEnv();
|
||||
@@ -64,7 +51,7 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
logger: LoggingRepository,
|
||||
private authService: AuthService,
|
||||
private eventRepository: EventRepository,
|
||||
private queueService: QueueService,
|
||||
|
||||
private telemetryRepository: TelemetryRepository,
|
||||
private websocketRepository: WebsocketRepository,
|
||||
) {
|
||||
@@ -74,22 +61,20 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
this.telemetryRepository.setup({ repositories });
|
||||
|
||||
this.queueService.setServices(services);
|
||||
|
||||
this.websocketRepository.setAuthFn(async (client) =>
|
||||
this.authService.authenticate({
|
||||
headers: client.request.headers,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
||||
metadata: { adminRoute: false, uri: '/api/socket.io' },
|
||||
}),
|
||||
);
|
||||
|
||||
this.eventRepository.setup({ services });
|
||||
await this.eventRepository.emit('AppBootstrap');
|
||||
await this.eventRepository.emit('app.bootstrap');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.eventRepository.emit('AppShutdown');
|
||||
await this.eventRepository.emit('app.shutdown');
|
||||
await teardownTelemetry();
|
||||
}
|
||||
}
|
||||
@@ -101,53 +86,8 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
})
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
@Module({
|
||||
imports: [...commonImports],
|
||||
controllers: [MaintenanceWorkerController],
|
||||
providers: [
|
||||
ConfigRepository,
|
||||
LoggingRepository,
|
||||
StorageRepository,
|
||||
ProcessRepository,
|
||||
DatabaseRepository,
|
||||
SystemMetadataRepository,
|
||||
AppRepository,
|
||||
MaintenanceHealthRepository,
|
||||
MaintenanceWebsocketRepository,
|
||||
MaintenanceWorkerService,
|
||||
...commonMiddleware,
|
||||
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
|
||||
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
|
||||
],
|
||||
})
|
||||
export class MaintenanceModule {
|
||||
constructor(
|
||||
@Inject(IWorker) private worker: ImmichWorker,
|
||||
logger: LoggingRepository,
|
||||
private maintenanceWorkerService: MaintenanceWorkerService,
|
||||
) {
|
||||
logger.setAppName(this.worker);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.maintenanceWorkerService.init();
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [...bullImports, ...commonImports],
|
||||
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
|
||||
})
|
||||
export class MicroservicesModule extends BaseModule {}
|
||||
|
||||
@Module({
|
||||
imports: [...bullImports, ...commonImports],
|
||||
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
|
||||
})
|
||||
export class ImmichAdminModule implements OnModuleDestroy {
|
||||
constructor(private service: CliService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.service.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
|
||||
|
||||
import { Kysely, sql } from 'kysely';
|
||||
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';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema';
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2];
|
||||
const path = process.argv[3] || 'src/Migration';
|
||||
|
||||
switch (command) {
|
||||
case 'debug': {
|
||||
await debug();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'run': {
|
||||
await runMigrations();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'revert': {
|
||||
await revert();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'query': {
|
||||
const query = process.argv[3];
|
||||
await runQuery(query);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
create(path, [], []);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'generate': {
|
||||
await generate(path);
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`Usage:
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getDatabaseClient = () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
return new Kysely<any>(getKyselyConfig(database.config));
|
||||
};
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
const db = getDatabaseClient();
|
||||
await sql.raw(query).execute(db);
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
const runMigrations = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const logger = LoggingRepository.create();
|
||||
const db = getDatabaseClient();
|
||||
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
|
||||
await databaseRepository.runMigrations();
|
||||
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');
|
||||
// const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
|
||||
writeFileSync('./migrations.sql', upSql + '\n\n');
|
||||
console.log('Wrote migrations.sql');
|
||||
};
|
||||
|
||||
const generate = async (path: string) => {
|
||||
const { up, down } = await compare();
|
||||
if (up.items.length === 0) {
|
||||
console.log('No changes detected');
|
||||
return;
|
||||
}
|
||||
create(path, up.asSql(), down.asSql());
|
||||
};
|
||||
|
||||
const create = (path: string, up: string[], down: string[]) => {
|
||||
const timestamp = Date.now();
|
||||
const name = basename(path, extname(path));
|
||||
const filename = `${timestamp}-${name}.ts`;
|
||||
const folder = dirname(path);
|
||||
const fullPath = join(folder, filename);
|
||||
mkdirSync(folder, { recursive: true });
|
||||
writeFileSync(fullPath, asMigration({ up, down }));
|
||||
console.log(`Wrote ${fullPath}`);
|
||||
};
|
||||
|
||||
const compare = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
const db = postgres(asPostgresConnectionConfig(database.config));
|
||||
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const target = await schemaFromDatabase(db, {});
|
||||
|
||||
console.log(source.warnings.join('\n'));
|
||||
|
||||
const up = schemaDiff(source, target, {
|
||||
tables: { ignoreExtra: true },
|
||||
functions: { ignoreExtra: false },
|
||||
parameters: { ignoreExtra: true },
|
||||
});
|
||||
const down = schemaDiff(target, source, {
|
||||
tables: { ignoreExtra: false, ignoreMissing: true },
|
||||
functions: { ignoreExtra: false },
|
||||
extensions: { ignoreMissing: true },
|
||||
parameters: { ignoreMissing: true },
|
||||
});
|
||||
|
||||
return { up, down };
|
||||
};
|
||||
|
||||
type MigrationProps = {
|
||||
up: string[];
|
||||
down: string[];
|
||||
};
|
||||
|
||||
const asMigration = ({ up, down }: MigrationProps) => {
|
||||
const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
|
||||
const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
|
||||
|
||||
return `import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
${upSql}
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
${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);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
console.log('Something went wrong');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ApiModule } from 'src/app.module';
|
||||
import { useSwagger } from 'src/utils/misc';
|
||||
|
||||
const sync = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { preview: true });
|
||||
useSwagger(app, { write: true });
|
||||
await app.close();
|
||||
};
|
||||
|
||||
sync()
|
||||
.then(() => {
|
||||
console.log('Done');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
console.log('Something went wrong');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { KyselyModule } from 'nestjs-kysely';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { format } from 'sql-formatter';
|
||||
import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
|
||||
import { repositories } from 'src/repositories';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const handleError = (label: string, error: Error | any) => {
|
||||
console.error(`${label} error: ${error}`);
|
||||
};
|
||||
|
||||
export class SqlLogger {
|
||||
queries: string[] = [];
|
||||
errors: Array<{ error: string | Error; query: string }> = [];
|
||||
|
||||
clear() {
|
||||
this.queries = [];
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
logQuery(query: string) {
|
||||
this.queries.push(format(query, { language: 'postgresql' }));
|
||||
}
|
||||
|
||||
logQueryError(error: string | Error, query: string) {
|
||||
this.errors.push({ error, query });
|
||||
}
|
||||
}
|
||||
|
||||
const reflector = new Reflector();
|
||||
|
||||
type Repository = ClassConstructor<any>;
|
||||
type SqlGeneratorOptions = { targetDir: string };
|
||||
|
||||
class SqlGenerator {
|
||||
private app: INestApplication | null = null;
|
||||
private sqlLogger = new SqlLogger();
|
||||
private results: Record<string, string[]> = {};
|
||||
|
||||
constructor(private options: SqlGeneratorOptions) {}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
await this.setup();
|
||||
for (const Repository of repositories) {
|
||||
if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
|
||||
continue;
|
||||
}
|
||||
await this.process(Repository);
|
||||
}
|
||||
await this.write();
|
||||
this.stats();
|
||||
} finally {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async setup() {
|
||||
await rm(this.options.targetDir, { force: true, recursive: true });
|
||||
await mkdir(this.options.targetDir);
|
||||
|
||||
if (!process.env.DB_HOSTNAME) {
|
||||
process.env.DB_HOSTNAME = 'localhost';
|
||||
}
|
||||
const { database, cls, otel } = new ConfigRepository().getEnv();
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [
|
||||
KyselyModule.forRoot({
|
||||
...getKyselyConfig(database.config),
|
||||
log: (event) => {
|
||||
if (event.level === 'query') {
|
||||
this.sqlLogger.logQuery(event.query.sql);
|
||||
} else if (event.level === 'error') {
|
||||
this.sqlLogger.logQueryError(event.error as Error, event.query.sql);
|
||||
this.sqlLogger.logQuery(event.query.sql);
|
||||
}
|
||||
},
|
||||
}),
|
||||
ClsModule.forRoot(cls.config),
|
||||
OpenTelemetryModule.forRoot(otel),
|
||||
],
|
||||
providers: [...repositories, AuthService, SchedulerRegistry],
|
||||
}).compile();
|
||||
|
||||
this.app = await moduleFixture.createNestApplication().init();
|
||||
}
|
||||
|
||||
async process(Repository: Repository) {
|
||||
if (!this.app) {
|
||||
throw new Error('Not initialized');
|
||||
}
|
||||
|
||||
const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`];
|
||||
const instance = this.app.get<Repository>(Repository);
|
||||
|
||||
// normal repositories
|
||||
data.push(...(await this.runTargets(instance, `${Repository.name}`)));
|
||||
|
||||
// nested repositories
|
||||
if (Repository.name === AccessRepository.name || Repository.name === SyncRepository.name) {
|
||||
for (const key of Object.keys(instance)) {
|
||||
const subInstance = (instance as any)[key];
|
||||
data.push(...(await this.runTargets(subInstance, `${Repository.name}.${key}`)));
|
||||
}
|
||||
}
|
||||
|
||||
this.results[Repository.name] = data;
|
||||
}
|
||||
|
||||
private async runTargets(instance: any, label: string) {
|
||||
const data: string[] = [];
|
||||
|
||||
for (const key of this.getPropertyNames(instance)) {
|
||||
const target = instance[key];
|
||||
if (!(typeof target === 'function')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queries = reflector.get<GenerateSqlQueries[] | undefined>(GENERATE_SQL_KEY, target);
|
||||
if (!queries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// empty decorator implies calling with no arguments
|
||||
if (queries.length === 0) {
|
||||
queries.push({ params: [] });
|
||||
}
|
||||
|
||||
for (const { name, params, stream } of queries) {
|
||||
let queryLabel = `${label}.${key}`;
|
||||
if (name) {
|
||||
queryLabel += ` (${name})`;
|
||||
}
|
||||
|
||||
this.sqlLogger.clear();
|
||||
|
||||
if (stream) {
|
||||
try {
|
||||
const result: AsyncIterableIterator<unknown> = target.apply(instance, params);
|
||||
for await (const _ of result) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(queryLabel, error);
|
||||
}
|
||||
} else {
|
||||
// errors still generate sql, which is all we care about
|
||||
await target.apply(instance, params).catch((error: Error) => handleError(queryLabel, error));
|
||||
}
|
||||
|
||||
if (this.sqlLogger.queries.length === 0) {
|
||||
console.warn(`No queries recorded for ${queryLabel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
data.push([`-- ${queryLabel}`, ...this.sqlLogger.queries].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private async write() {
|
||||
for (const [repoName, data] of Object.entries(this.results)) {
|
||||
// only contains the header
|
||||
if (data.length === 1) {
|
||||
continue;
|
||||
}
|
||||
const filename = repoName.replaceAll(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', '');
|
||||
const file = join(this.options.targetDir, `${filename}.sql`);
|
||||
await writeFile(file, data.join('\n\n') + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
private stats() {
|
||||
console.log(`Wrote ${Object.keys(this.results).length} files`);
|
||||
console.log(`Generated ${Object.values(this.results).flat().length} queries`);
|
||||
}
|
||||
|
||||
private async close() {
|
||||
if (this.app) {
|
||||
await this.app.close();
|
||||
}
|
||||
}
|
||||
|
||||
private getPropertyNames(instance: any): string[] {
|
||||
return Object.getOwnPropertyNames(Object.getPrototypeOf(instance)) as any[];
|
||||
}
|
||||
}
|
||||
|
||||
new SqlGenerator({ targetDir: './src/queries' })
|
||||
.run()
|
||||
.then(() => {
|
||||
console.log('Done');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
console.log('Something went wrong');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
const prompt = (inquirer: InquirerService) => {
|
||||
return function ask(): Promise<string> {
|
||||
return inquirer.ask<{ email: string }>('prompt-email', {}).then(({ email }: { email: string }) => email);
|
||||
};
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'grant-admin',
|
||||
description: 'Grant admin privileges to a user (by email)',
|
||||
})
|
||||
export class GrantAdminCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const email = await prompt(this.inquirer)();
|
||||
await this.service.grantAdminAccess(email);
|
||||
console.debug('Admin access has been granted to', email);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to grant admin access to user');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'revoke-admin',
|
||||
description: 'Revoke admin privileges from a user (by email)',
|
||||
})
|
||||
export class RevokeAdminCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const email = await prompt(this.inquirer)();
|
||||
await this.service.revokeAdminAccess(email);
|
||||
console.debug('Admin access has been revoked from', email);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to revoke admin access from user');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-email' })
|
||||
export class PromptEmailQuestion {
|
||||
@Question({
|
||||
message: 'Please enter the user email: ',
|
||||
name: 'email',
|
||||
})
|
||||
parseEmail(value: string) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
PromptMediaLocationQuestions,
|
||||
} from 'src/commands/media-location.command';
|
||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
import { VersionCommand } from 'src/commands/version.command';
|
||||
|
||||
export const commandsAndQuestions = [
|
||||
ResetAdminPasswordCommand,
|
||||
PromptPasswordQuestions,
|
||||
PromptEmailQuestion,
|
||||
EnablePasswordLoginCommand,
|
||||
DisablePasswordLoginCommand,
|
||||
EnableMaintenanceModeCommand,
|
||||
DisableMaintenanceModeCommand,
|
||||
EnableOAuthLogin,
|
||||
DisableOAuthLogin,
|
||||
ListUsersCommand,
|
||||
VersionCommand,
|
||||
GrantAdminCommand,
|
||||
RevokeAdminCommand,
|
||||
ChangeMediaLocationCommand,
|
||||
PromptMediaLocationQuestions,
|
||||
PromptConfirmMoveQuestions,
|
||||
];
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'list-users',
|
||||
description: 'List Immich users',
|
||||
})
|
||||
export class ListUsersCommand extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const users = await this.service.listUsers();
|
||||
console.dir(users);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to load users');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,106 +0,0 @@
|
||||
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'change-media-location',
|
||||
description: 'Change database file paths to align with a new media location',
|
||||
})
|
||||
export class ChangeMediaLocationCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private async showSamplePaths(hint?: string) {
|
||||
hint = hint ? ` (${hint})` : '';
|
||||
|
||||
const paths = await this.service.getSampleFilePaths();
|
||||
if (paths.length > 0) {
|
||||
let message = ` Examples from the database${hint}:\n`;
|
||||
for (const path of paths) {
|
||||
message += ` - ${path}\n`;
|
||||
}
|
||||
|
||||
console.log(`\n${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
await this.showSamplePaths();
|
||||
|
||||
const { oldValue, newValue } = await this.inquirer.ask<{ oldValue: string; newValue: string }>(
|
||||
'prompt-media-location',
|
||||
{},
|
||||
);
|
||||
|
||||
const success = await this.service.migrateFilePaths({
|
||||
oldValue,
|
||||
newValue,
|
||||
confirm: async ({ sourceFolder, targetFolder }) => {
|
||||
console.log(`
|
||||
Previous value: ${oldValue}
|
||||
Current value: ${newValue}
|
||||
|
||||
Changing from "${sourceFolder}/*" to "${targetFolder}/*"
|
||||
`);
|
||||
|
||||
const { value: confirmed } = await this.inquirer.ask<{ value: boolean }>('prompt-confirm-move', {});
|
||||
return confirmed;
|
||||
},
|
||||
});
|
||||
|
||||
const successMessage = `Matching database file paths were updated successfully! 🎉
|
||||
|
||||
You may now set IMMICH_MEDIA_LOCATION=${newValue} and restart!
|
||||
|
||||
(please remember to update applicable volume mounts e.g
|
||||
services:
|
||||
immich-server:
|
||||
...
|
||||
volumes:
|
||||
- \${UPLOAD_LOCATION}:/data
|
||||
...
|
||||
)`;
|
||||
|
||||
console.log(`\n ${success ? successMessage : 'No rows were updated'}\n`);
|
||||
|
||||
await this.showSamplePaths('after');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to update database file paths.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentValue = process.env.IMMICH_MEDIA_LOCATION || '';
|
||||
|
||||
const makePrompt = (which: string) => {
|
||||
return `Enter the ${which} value of IMMICH_MEDIA_LOCATION:${currentValue ? ` [${currentValue}]` : ''}`;
|
||||
};
|
||||
|
||||
@QuestionSet({ name: 'prompt-media-location' })
|
||||
export class PromptMediaLocationQuestions {
|
||||
@Question({ message: makePrompt('previous'), name: 'oldValue' })
|
||||
oldValue(value: string) {
|
||||
return value || currentValue;
|
||||
}
|
||||
|
||||
@Question({ message: makePrompt('new'), name: 'newValue' })
|
||||
newValue(value: string) {
|
||||
return value || currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-confirm-move' })
|
||||
export class PromptConfirmMoveQuestions {
|
||||
@Question({
|
||||
message: 'Do you want to proceed? [Y/n]',
|
||||
name: 'value',
|
||||
})
|
||||
value(value: string): boolean {
|
||||
return ['yes', 'y'].includes((value || 'y').toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'enable-oauth-login',
|
||||
description: 'Enable OAuth login',
|
||||
})
|
||||
export class EnableOAuthLogin extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.service.enableOAuthLogin();
|
||||
console.log('OAuth login has been enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'disable-oauth-login',
|
||||
description: 'Disable OAuth login',
|
||||
})
|
||||
export class DisableOAuthLogin extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.service.disableOAuthLogin();
|
||||
console.log('OAuth login has been disabled.');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'enable-password-login',
|
||||
description: 'Enable password login',
|
||||
})
|
||||
export class EnablePasswordLoginCommand extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.service.enablePasswordLogin();
|
||||
console.log('Password login has been enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'disable-password-login',
|
||||
description: 'Disable password login',
|
||||
})
|
||||
export class DisablePasswordLoginCommand extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.service.disablePasswordLogin();
|
||||
console.log('Password login has been disabled.');
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
const prompt = (inquirer: InquirerService) => {
|
||||
return function ask(admin: UserAdminResponseDto) {
|
||||
const { id, oauthId, email, name } = admin;
|
||||
console.log(`Found Admin:
|
||||
- ID=${id}
|
||||
- OAuth ID=${oauthId}
|
||||
- Email=${email}
|
||||
- Name=${name}`);
|
||||
|
||||
return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
|
||||
};
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'reset-admin-password',
|
||||
description: 'Reset the admin password',
|
||||
})
|
||||
export class ResetAdminPasswordCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const { password, provided } = await this.service.resetAdminPassword(prompt(this.inquirer));
|
||||
|
||||
if (provided) {
|
||||
console.log(`The admin password has been updated.`);
|
||||
} else {
|
||||
console.log(`The admin password has been updated to:\n${password}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to reset admin password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-password' })
|
||||
export class PromptPasswordQuestions {
|
||||
@Question({
|
||||
message: 'Please choose a new password (optional)',
|
||||
name: 'password',
|
||||
})
|
||||
parsePassword(value: string) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
|
||||
@Command({
|
||||
name: 'version',
|
||||
description: 'Print Immich version',
|
||||
})
|
||||
export class VersionCommand extends CommandRunner {
|
||||
constructor(private service: VersionService) {
|
||||
super();
|
||||
}
|
||||
|
||||
run(): Promise<void> {
|
||||
try {
|
||||
const version = this.service.getVersion();
|
||||
console.log(`v${version.major}.${version.minor}.${version.patch}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to get version');
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,394 +1,43 @@
|
||||
import { CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
OAuthTokenEndpointAuthMethod,
|
||||
QueueName,
|
||||
ToneMapping,
|
||||
TranscodeHardwareAcceleration,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
VideoContainer,
|
||||
} from 'src/enum';
|
||||
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
|
||||
import { LogLevel, QueueName } from 'src/enum';
|
||||
|
||||
export type SystemConfig = {
|
||||
backup: {
|
||||
database: {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
keepLastAmount: number;
|
||||
};
|
||||
};
|
||||
ffmpeg: {
|
||||
crf: number;
|
||||
threads: number;
|
||||
preset: string;
|
||||
targetVideoCodec: VideoCodec;
|
||||
acceptedVideoCodecs: VideoCodec[];
|
||||
targetAudioCodec: AudioCodec;
|
||||
acceptedAudioCodecs: AudioCodec[];
|
||||
acceptedContainers: VideoContainer[];
|
||||
targetResolution: string;
|
||||
maxBitrate: string;
|
||||
bframes: number;
|
||||
refs: number;
|
||||
gopSize: number;
|
||||
temporalAQ: boolean;
|
||||
cqMode: CQMode;
|
||||
twoPass: boolean;
|
||||
preferredHwDevice: string;
|
||||
transcode: TranscodePolicy;
|
||||
accel: TranscodeHardwareAcceleration;
|
||||
accelDecode: boolean;
|
||||
tonemap: ToneMapping;
|
||||
};
|
||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
||||
job: Record<QueueName, { concurrency: number }>;
|
||||
logging: {
|
||||
enabled: boolean;
|
||||
level: LogLevel;
|
||||
};
|
||||
machineLearning: {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
availabilityChecks: {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
};
|
||||
clip: {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
};
|
||||
duplicateDetection: {
|
||||
enabled: boolean;
|
||||
maxDistance: number;
|
||||
};
|
||||
facialRecognition: {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
minScore: number;
|
||||
minFaces: number;
|
||||
maxDistance: number;
|
||||
};
|
||||
ocr: {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
minDetectionScore: number;
|
||||
minRecognitionScore: number;
|
||||
maxResolution: number;
|
||||
};
|
||||
};
|
||||
map: {
|
||||
enabled: boolean;
|
||||
lightStyle: string;
|
||||
darkStyle: string;
|
||||
};
|
||||
reverseGeocoding: {
|
||||
enabled: boolean;
|
||||
};
|
||||
metadata: {
|
||||
faces: {
|
||||
import: boolean;
|
||||
};
|
||||
};
|
||||
oauth: {
|
||||
autoLaunch: boolean;
|
||||
autoRegister: boolean;
|
||||
buttonText: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
defaultStorageQuota: number | null;
|
||||
enabled: boolean;
|
||||
issuerUrl: string;
|
||||
mobileOverrideEnabled: boolean;
|
||||
mobileRedirectUri: string;
|
||||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
profileSigningAlgorithm: string;
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
||||
timeout: number;
|
||||
storageLabelClaim: string;
|
||||
storageQuotaClaim: string;
|
||||
roleClaim: string;
|
||||
};
|
||||
passwordLogin: {
|
||||
enabled: boolean;
|
||||
};
|
||||
storageTemplate: {
|
||||
enabled: boolean;
|
||||
hashVerificationEnabled: boolean;
|
||||
template: string;
|
||||
};
|
||||
image: {
|
||||
thumbnail: ImageOptions;
|
||||
preview: ImageOptions;
|
||||
colorspace: Colorspace;
|
||||
extractEmbedded: boolean;
|
||||
fullsize: FullsizeImageOptions;
|
||||
};
|
||||
newVersionCheck: {
|
||||
enabled: boolean;
|
||||
};
|
||||
nightlyTasks: {
|
||||
startTime: string;
|
||||
databaseCleanup: boolean;
|
||||
missingThumbnails: boolean;
|
||||
clusterNewFaces: boolean;
|
||||
generateMemories: boolean;
|
||||
syncQuotaUsage: boolean;
|
||||
};
|
||||
trash: {
|
||||
enabled: boolean;
|
||||
days: number;
|
||||
};
|
||||
theme: {
|
||||
customCss: string;
|
||||
};
|
||||
library: {
|
||||
scan: {
|
||||
enabled: boolean;
|
||||
cronExpression: string;
|
||||
};
|
||||
watch: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
notifications: {
|
||||
smtp: {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
transport: {
|
||||
ignoreCert: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
templates: {
|
||||
email: {
|
||||
welcomeTemplate: string;
|
||||
albumInviteTemplate: string;
|
||||
albumUpdateTemplate: string;
|
||||
};
|
||||
};
|
||||
server: {
|
||||
externalDomain: string;
|
||||
loginPageMessage: string;
|
||||
publicUsers: boolean;
|
||||
};
|
||||
theme: {
|
||||
customCss: string;
|
||||
};
|
||||
user: {
|
||||
deleteDelay: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
export const defaults = Object.freeze<SystemConfig>({
|
||||
backup: {
|
||||
database: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
||||
keepLastAmount: 14,
|
||||
},
|
||||
},
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: CQMode.Auto,
|
||||
twoPass: false,
|
||||
preferredHwDevice: 'auto',
|
||||
transcode: TranscodePolicy.Required,
|
||||
tonemap: ToneMapping.Hable,
|
||||
accel: TranscodeHardwareAcceleration.Disabled,
|
||||
accelDecode: false,
|
||||
},
|
||||
job: {
|
||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
||||
[QueueName.SmartSearch]: { concurrency: 2 },
|
||||
[QueueName.MetadataExtraction]: { concurrency: 5 },
|
||||
[QueueName.FaceDetection]: { concurrency: 2 },
|
||||
[QueueName.Search]: { concurrency: 5 },
|
||||
[QueueName.Sidecar]: { concurrency: 5 },
|
||||
[QueueName.Library]: { concurrency: 5 },
|
||||
[QueueName.Migration]: { concurrency: 5 },
|
||||
[QueueName.ThumbnailGeneration]: { concurrency: 3 },
|
||||
[QueueName.VideoConversion]: { concurrency: 1 },
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
[QueueName.Editor]: { concurrency: 2 },
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
level: LogLevel.Log,
|
||||
},
|
||||
machineLearning: {
|
||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
|
||||
availabilityChecks: {
|
||||
enabled: true,
|
||||
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
|
||||
interval: 30_000,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
},
|
||||
duplicateDetection: {
|
||||
enabled: true,
|
||||
maxDistance: 0.01,
|
||||
},
|
||||
facialRecognition: {
|
||||
enabled: true,
|
||||
modelName: 'buffalo_l',
|
||||
minScore: 0.7,
|
||||
maxDistance: 0.5,
|
||||
minFaces: 3,
|
||||
},
|
||||
ocr: {
|
||||
enabled: true,
|
||||
modelName: 'PP-OCRv5_mobile',
|
||||
minDetectionScore: 0.5,
|
||||
minRecognitionScore: 0.8,
|
||||
maxResolution: 736,
|
||||
},
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
faces: {
|
||||
import: false,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
autoLaunch: false,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with OAuth',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
defaultStorageQuota: null,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
roleClaim: 'immich_role',
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
|
||||
timeout: 30_000,
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
storageTemplate: {
|
||||
enabled: false,
|
||||
hashVerificationEnabled: true,
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
image: {
|
||||
thumbnail: {
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
fullsize: {
|
||||
enabled: false,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
},
|
||||
newVersionCheck: {
|
||||
enabled: true,
|
||||
},
|
||||
nightlyTasks: {
|
||||
startTime: '00:00',
|
||||
databaseCleanup: true,
|
||||
generateMemories: true,
|
||||
syncQuotaUsage: true,
|
||||
missingThumbnails: true,
|
||||
clusterNewFaces: true,
|
||||
},
|
||||
trash: {
|
||||
enabled: true,
|
||||
days: 30,
|
||||
},
|
||||
theme: {
|
||||
customCss: '',
|
||||
},
|
||||
library: {
|
||||
scan: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT,
|
||||
},
|
||||
watch: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
externalDomain: '',
|
||||
loginPageMessage: '',
|
||||
publicUsers: true,
|
||||
},
|
||||
notifications: {
|
||||
smtp: {
|
||||
enabled: false,
|
||||
from: '',
|
||||
replyTo: '',
|
||||
transport: {
|
||||
ignoreCert: false,
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
email: {
|
||||
welcomeTemplate: '',
|
||||
albumInviteTemplate: '',
|
||||
albumUpdateTemplate: '',
|
||||
},
|
||||
theme: {
|
||||
customCss: '',
|
||||
},
|
||||
user: {
|
||||
deleteDelay: 7,
|
||||
|
||||
@@ -1,37 +1,16 @@
|
||||
import { Duration } from 'luxon';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
import { ApiTag, DatabaseExtension } from 'src/enum';
|
||||
|
||||
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
|
||||
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
|
||||
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
|
||||
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000;
|
||||
|
||||
export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
|
||||
cube: 'cube',
|
||||
earthdistance: 'earthdistance',
|
||||
vector: 'pgvector',
|
||||
vectors: 'pgvecto.rs',
|
||||
vchord: 'VectorChord',
|
||||
} as const;
|
||||
|
||||
export const VECTOR_EXTENSIONS = [
|
||||
DatabaseExtension.VectorChord,
|
||||
DatabaseExtension.Vectors,
|
||||
DatabaseExtension.Vector,
|
||||
] as const;
|
||||
|
||||
export const VECTOR_INDEX_TABLES = {
|
||||
[VectorIndex.Clip]: 'smart_search',
|
||||
[VectorIndex.Face]: 'face_search',
|
||||
} as const;
|
||||
|
||||
export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
|
||||
export const VECTOR_EXTENSIONS = [DatabaseExtension.Vector] as const;
|
||||
|
||||
export const SALT_ROUNDS = 10;
|
||||
|
||||
@@ -43,154 +22,14 @@ const packageFile = join(basePath, '..', 'package.json');
|
||||
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
|
||||
export const serverVersion = new SemVer(version);
|
||||
|
||||
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
|
||||
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
|
||||
|
||||
export const citiesFile = 'cities500.txt';
|
||||
export const reverseGeocodeMaxDistance = 25_000;
|
||||
|
||||
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
||||
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
||||
|
||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
|
||||
type ModelInfo = { dimSize: number };
|
||||
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
||||
RN101__openai: { dimSize: 512 },
|
||||
RN101__yfcc15m: { dimSize: 512 },
|
||||
'ViT-B-16__laion400m_e31': { dimSize: 512 },
|
||||
'ViT-B-16__laion400m_e32': { dimSize: 512 },
|
||||
'ViT-B-16__openai': { dimSize: 512 },
|
||||
'ViT-B-32__laion2b-s34b-b79k': { dimSize: 512 },
|
||||
'ViT-B-32__laion2b_e16': { dimSize: 512 },
|
||||
'ViT-B-32__laion400m_e31': { dimSize: 512 },
|
||||
'ViT-B-32__laion400m_e32': { dimSize: 512 },
|
||||
'ViT-B-32__openai': { dimSize: 512 },
|
||||
'XLM-Roberta-Base-ViT-B-32__laion5b_s13b_b90k': { dimSize: 512 },
|
||||
'XLM-Roberta-Large-Vit-B-32': { dimSize: 512 },
|
||||
RN50x4__openai: { dimSize: 640 },
|
||||
'ViT-B-16-plus-240__laion400m_e31': { dimSize: 640 },
|
||||
'ViT-B-16-plus-240__laion400m_e32': { dimSize: 640 },
|
||||
'XLM-Roberta-Large-Vit-B-16Plus': { dimSize: 640 },
|
||||
'LABSE-Vit-L-14': { dimSize: 768 },
|
||||
RN50x16__openai: { dimSize: 768 },
|
||||
'ViT-B-16-SigLIP-256__webli': { dimSize: 768 },
|
||||
'ViT-B-16-SigLIP-384__webli': { dimSize: 768 },
|
||||
'ViT-B-16-SigLIP-512__webli': { dimSize: 768 },
|
||||
'ViT-B-16-SigLIP-i18n-256__webli': { dimSize: 768 },
|
||||
'ViT-B-16-SigLIP__webli': { dimSize: 768 },
|
||||
'ViT-L-14-336__openai': { dimSize: 768 },
|
||||
'ViT-L-14-quickgelu__dfn2b': { dimSize: 768 },
|
||||
'ViT-L-14__laion2b-s32b-b82k': { dimSize: 768 },
|
||||
'ViT-L-14__laion400m_e31': { dimSize: 768 },
|
||||
'ViT-L-14__laion400m_e32': { dimSize: 768 },
|
||||
'ViT-L-14__openai': { dimSize: 768 },
|
||||
'XLM-Roberta-Large-Vit-L-14': { dimSize: 768 },
|
||||
'nllb-clip-base-siglip__mrl': { dimSize: 768 },
|
||||
'nllb-clip-base-siglip__v1': { dimSize: 768 },
|
||||
RN50__cc12m: { dimSize: 1024 },
|
||||
RN50__openai: { dimSize: 1024 },
|
||||
RN50__yfcc15m: { dimSize: 1024 },
|
||||
RN50x64__openai: { dimSize: 1024 },
|
||||
'ViT-H-14-378-quickgelu__dfn5b': { dimSize: 1024 },
|
||||
'ViT-H-14-quickgelu__dfn5b': { dimSize: 1024 },
|
||||
'ViT-H-14__laion2b-s32b-b79k': { dimSize: 1024 },
|
||||
'ViT-L-16-SigLIP-256__webli': { dimSize: 1024 },
|
||||
'ViT-L-16-SigLIP-384__webli': { dimSize: 1024 },
|
||||
'ViT-g-14__laion2b-s12b-b42k': { dimSize: 1024 },
|
||||
'XLM-Roberta-Large-ViT-H-14__frozen_laion5b_s13b_b90k': { dimSize: 1024 },
|
||||
'ViT-SO400M-14-SigLIP-384__webli': { dimSize: 1152 },
|
||||
'nllb-clip-large-siglip__mrl': { dimSize: 1152 },
|
||||
'nllb-clip-large-siglip__v1': { dimSize: 1152 },
|
||||
'ViT-B-16-SigLIP2__webli': { dimSize: 768 },
|
||||
'ViT-B-32-SigLIP2-256__webli': { dimSize: 768 },
|
||||
'ViT-L-16-SigLIP2-256__webli': { dimSize: 1024 },
|
||||
'ViT-L-16-SigLIP2-384__webli': { dimSize: 1024 },
|
||||
'ViT-L-16-SigLIP2-512__webli': { dimSize: 1024 },
|
||||
'ViT-SO400M-14-SigLIP2__webli': { dimSize: 1152 },
|
||||
'ViT-SO400M-14-SigLIP2-378__webli': { dimSize: 1152 },
|
||||
'ViT-SO400M-16-SigLIP2-256__webli': { dimSize: 1152 },
|
||||
'ViT-SO400M-16-SigLIP2-384__webli': { dimSize: 1152 },
|
||||
'ViT-SO400M-16-SigLIP2-512__webli': { dimSize: 1152 },
|
||||
'ViT-gopt-16-SigLIP2-256__webli': { dimSize: 1536 },
|
||||
'ViT-gopt-16-SigLIP2-384__webli': { dimSize: 1536 },
|
||||
};
|
||||
|
||||
type SharpRotationData = {
|
||||
angle?: number;
|
||||
flip?: boolean;
|
||||
flop?: boolean;
|
||||
};
|
||||
export const ORIENTATION_TO_SHARP_ROTATION: Record<ExifOrientation, SharpRotationData> = {
|
||||
[ExifOrientation.Horizontal]: { angle: 0 },
|
||||
[ExifOrientation.MirrorHorizontal]: { angle: 0, flop: true },
|
||||
[ExifOrientation.Rotate180]: { angle: 180 },
|
||||
[ExifOrientation.MirrorVertical]: { angle: 180, flop: true },
|
||||
[ExifOrientation.MirrorHorizontalRotate270CW]: { angle: 270, flip: true },
|
||||
[ExifOrientation.Rotate90CW]: { angle: 90 },
|
||||
[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.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||
[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.',
|
||||
[ApiTag.ApiKeys]: 'An API key can be used to programmatically access the API.',
|
||||
[ApiTag.Authentication]: 'Endpoints related to user authentication.',
|
||||
[ApiTag.Server]: 'Information about the current server deployment, including version, features, and health.',
|
||||
[ApiTag.Sessions]: 'A session represents an authenticated login session for a user.',
|
||||
[ApiTag.Users]: 'Endpoints for viewing and updating the current user.',
|
||||
};
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ActivityController } from 'src/controllers/activity.controller';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(ActivityController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(ActivityService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(ActivityController, [{ provide: ActivityService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /activities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should reject an invalid albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get('/activities')
|
||||
.query({ albumId: factory.uuid(), assetId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /activities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/activities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require an albumId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/activities').send({ albumId: '123' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a comment when type is comment', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/activities')
|
||||
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['comment must be a string', 'comment should not be empty']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /activities/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/activities/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
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,
|
||||
ActivityResponseDto,
|
||||
ActivitySearchDto,
|
||||
ActivityStatisticsResponseDto,
|
||||
} from 'src/dtos/activity.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
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(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,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<ActivityResponseDto> {
|
||||
const { duplicate, value } = await this.service.create(auth, dto);
|
||||
if (duplicate) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(AlbumController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(AlbumService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AlbumController, [{ provide: AlbumService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /albums', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/albums');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['shared must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject an invalid assetId param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['assetId must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /albums/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/albums/statistics');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /albums', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/albums').send({ albumName: 'New album' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /albums/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT :id/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/${factory.uuid()}/users`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
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,
|
||||
AlbumResponseDto,
|
||||
AlbumsAddAssetsDto,
|
||||
AlbumsAddAssetsResponseDto,
|
||||
AlbumStatisticsResponseDto,
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
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(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,
|
||||
@Query() dto: AlbumInfoDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.get(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: UpdateAlbumDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: BulkIdsDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.addAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.removeAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: AddUsersDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.addUsers(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
|
||||
@Body() dto: UpdateAlbumUserDto,
|
||||
): Promise<void> {
|
||||
return this.service.updateUser(auth, id, userId, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
|
||||
): Promise<void> {
|
||||
return this.service.removeUser(auth, id, userId);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(ApiKeyController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(ApiKeyService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /api-keys', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/api-keys').send({ name: 'API Key' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/api-keys');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/me`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/api-keys/${factory.uuid()}`).send({ name: 'new name' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/api-keys/123`)
|
||||
.send({ name: 'new name', permissions: [Permission.All] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should allow updating just the name', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put(`/api-keys/${factory.uuid()}`)
|
||||
.send({ name: 'new name' });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api-keys/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/api-keys/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(AppController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AppController, [
|
||||
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /.well-known/immich', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/.well-known/immich');
|
||||
expect(ctx.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a 200 status code', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/.well-known/immich');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
api: {
|
||||
endpoint: '/api',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /custom.css', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/custom.css');
|
||||
expect(ctx.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reply with text/css', async () => {
|
||||
const { status, headers } = await request(ctx.getHttpServer()).get('/custom.css');
|
||||
expect(status).toBe(200);
|
||||
expect(headers['content-type']).toEqual('text/css; charset=utf-8');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,18 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private service: SystemConfigService) {}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Get('.well-known/immich')
|
||||
getImmichWellKnown() {
|
||||
return {
|
||||
api: {
|
||||
endpoint: '/api',
|
||||
},
|
||||
};
|
||||
return { api: { endpoint: '/api' } };
|
||||
}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@Get('custom.css')
|
||||
@Header('Content-Type', 'text/css')
|
||||
getCustomCss() {
|
||||
return this.service.getCustomCss();
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
|
||||
const dto: Record<string, any> = {
|
||||
deviceAssetId: 'example-image',
|
||||
deviceId: 'TEST',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
isFavorite: 'false',
|
||||
duration: '0:00:00.000000',
|
||||
};
|
||||
|
||||
const omit = options?.omit;
|
||||
if (omit) {
|
||||
delete dto[omit];
|
||||
}
|
||||
|
||||
return dto;
|
||||
};
|
||||
|
||||
describe(AssetMediaController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const assetData = Buffer.from('123');
|
||||
const filename = 'example.png';
|
||||
const service = mockBaseService(AssetMediaService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetMediaController, [
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
{ provide: AssetMediaService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
|
||||
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept metadata', async () => {
|
||||
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({
|
||||
...makeUploadDto(),
|
||||
metadata: JSON.stringify([mobileMetadata]),
|
||||
});
|
||||
|
||||
expect(service.uploadAsset).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ metadata: [mobileMetadata] }),
|
||||
expect.objectContaining({ originalName: 'example.png' }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle invalid metadata json', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({
|
||||
...makeUploadDto(),
|
||||
metadata: 'not-a-string-string',
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
|
||||
});
|
||||
|
||||
it('should require `deviceAssetId`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `deviceId`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
|
||||
});
|
||||
|
||||
it('should require `fileCreatedAt`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `fileModifiedAt`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if `isFavorite` is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should throw if `visibility` is not an enum', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
|
||||
);
|
||||
});
|
||||
|
||||
// TODO figure out how to deal with `sendFile`
|
||||
describe.skip('GET /assets/:id/original', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO figure out how to deal with `sendFile`
|
||||
describe.skip('GET /assets/:id/thumbnail', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatus,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
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';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
export class AssetMediaController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: AssetMediaService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.Checksum,
|
||||
description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded',
|
||||
required: false,
|
||||
})
|
||||
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Asset is a duplicate',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Asset uploaded successfully',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@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,
|
||||
@Body() dto: AssetMediaCreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file, sidecarFile } = getFiles(files);
|
||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
||||
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@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,
|
||||
@Query() dto: AssetDownloadOriginalDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Asset replaced successfully',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@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(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaReplaceDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get(':id/thumbnail')
|
||||
@FileResponse()
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'View asset thumbnail',
|
||||
description:
|
||||
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async viewAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
||||
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
} else {
|
||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||
// which redirects to the original asset or a specific size to make better use of caching
|
||||
const { targetSize } = viewThumbnailRes;
|
||||
const [reqPath, reqSearch] = req.url.split('?');
|
||||
let redirPath: string;
|
||||
const redirSearchParams = new URLSearchParams(reqSearch);
|
||||
if (targetSize === 'original') {
|
||||
// relative path to this.downloadAsset
|
||||
redirPath = 'original';
|
||||
redirSearchParams.delete('size');
|
||||
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
|
||||
redirPath = reqPath;
|
||||
redirSearchParams.set('size', targetSize);
|
||||
} else {
|
||||
throw new Error('Invalid targetSize: ' + targetSize);
|
||||
}
|
||||
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
|
||||
return res.redirect(finalRedirPath);
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@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(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
return this.service.checkExistingAssets(auth, dto);
|
||||
}
|
||||
|
||||
@Post('bulk-upload-check')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@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(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: AssetBulkUploadCheckDto,
|
||||
): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
return this.service.bulkUploadCheck(auth, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(AssetController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(AssetService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.reset();
|
||||
service.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('PUT /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require duplicateId to be a string', async () => {
|
||||
const id = factory.uuid();
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: [id], duplicateId: true });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['duplicateId must be a string']));
|
||||
});
|
||||
|
||||
it('should accept a null duplicateId', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: [id], duplicateId: null });
|
||||
|
||||
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ duplicateId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.delete(`/assets`)
|
||||
.send({ ids: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/assets`)
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/copy', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/copy`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require target and source id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(expect.arrayContaining(['sourceId must be a UUID', 'targetId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/assets/copy')
|
||||
.send({ sourceId: factory.uuid(), targetId: factory.uuid() });
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid() }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] });
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid rating', async () => {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/statistics`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/random', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/random`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow count to be a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/assets/random?count=ABC');
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['count must be a positive number', 'count must be an integer number']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require items to be an array', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
|
||||
});
|
||||
|
||||
it('should require each item to have a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/metadata`)
|
||||
.send({ items: [{ value: { some: 'value' } }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require each item to have a value', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: null }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
|
||||
);
|
||||
});
|
||||
|
||||
describe(AssetMetadataKey.MobileApp, () => {
|
||||
it('should accept valid data and pass to service correctly', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${assetId}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
|
||||
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should work without iCloudId', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${assetId}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: {} }] });
|
||||
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||
items: [{ key: 'mobile-app', value: {} }],
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id/edits', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid edits and pass to service correctly', async () => {
|
||||
const edits = [
|
||||
{
|
||||
action: 'crop',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}/edits`).send({
|
||||
edits,
|
||||
});
|
||||
|
||||
expect(service.editAsset).toHaveBeenCalledWith(undefined, assetId, { edits });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/123/edits`)
|
||||
.send({
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require at least one edit', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({ edits: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
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 { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
AssetCopyDto,
|
||||
AssetJobsDto,
|
||||
AssetMetadataBulkDeleteDto,
|
||||
AssetMetadataBulkResponseDto,
|
||||
AssetMetadataBulkUpsertDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataRouteParams,
|
||||
AssetMetadataUpsertDto,
|
||||
AssetStatsDto,
|
||||
AssetStatsResponseDto,
|
||||
DeviceIdDto,
|
||||
RandomAssetsDto,
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
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(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
export class AssetController {
|
||||
constructor(private service: AssetService) {}
|
||||
|
||||
@Get('random')
|
||||
@Authenticated({ permission: Permission.AssetRead })
|
||||
@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('/device/:deviceId')
|
||||
@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) {
|
||||
return this.service.getUserAssetsByDeviceId(auth, deviceId);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Post('jobs')
|
||||
@Authenticated({ permission: Permission.JobCreate })
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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>;
|
||||
}
|
||||
|
||||
@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('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Upsert asset metadata',
|
||||
description: 'Upsert metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
updateBulkAssetMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: AssetMetadataBulkUpsertDto,
|
||||
): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
return this.service.upsertBulkMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Delete('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete asset metadata',
|
||||
description: 'Delete metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise<void> {
|
||||
return this.service.deleteBulkMetadata(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,
|
||||
@Body() dto: UpdateAssetDto,
|
||||
): Promise<AssetResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: AssetMetadataUpsertDto,
|
||||
): Promise<AssetMetadataResponseDto[]> {
|
||||
return this.service.upsertMetadata(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
): Promise<AssetMetadataResponseDto> {
|
||||
return this.service.getMetadataByKey(auth, id, key);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Get(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditGet })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve edits for an existing asset',
|
||||
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
|
||||
return this.service.getAssetEdits(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditCreate })
|
||||
@Endpoint({
|
||||
summary: 'Apply edits to an existing asset',
|
||||
description: 'Apply a series of edit actions (crop, rotate, mirror) to the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
editAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AssetEditActionListDto,
|
||||
): Promise<AssetEditsDto> {
|
||||
return this.service.editAsset(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Remove edits from an existing asset',
|
||||
description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.removeAssetEdits(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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 { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { LoginResponseDto } from 'src/dtos/auth.dto';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import request from 'supertest';
|
||||
import { mediumFactory } from 'test/medium.factory';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(AuthController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(AuthService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AuthController, [{ provide: AuthService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
const name = 'admin';
|
||||
const email = 'admin@immich.cloud';
|
||||
const password = 'password';
|
||||
|
||||
it('should require an email address', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a password', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should require a valid email', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name, email: 'immich', password });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
service.adminSignUp.mockReset();
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' });
|
||||
expect(status).toEqual(201);
|
||||
expect(service.adminSignUp).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@immich.cloud' }));
|
||||
});
|
||||
|
||||
it('should accept an email with a local domain', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name: 'admin', password: 'password', email: 'admin@local' });
|
||||
expect(status).toEqual(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it(`should require an email and password`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'email should not be empty',
|
||||
'email must be an email',
|
||||
'password should not be empty',
|
||||
'password must be a string',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should not allow null email`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: null, password: 'password' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['email should not be empty', 'email must be an email']));
|
||||
});
|
||||
|
||||
it(`should not allow null password`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['password should not be empty', 'password must be a string']));
|
||||
});
|
||||
|
||||
it('should reject an invalid email', async () => {
|
||||
service.login.mockResolvedValue({ accessToken: 'access-token' } as LoginResponseDto);
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: [], password: 'password' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['email must be an email']));
|
||||
});
|
||||
|
||||
it('should transform the email to all lowercase', async () => {
|
||||
service.login.mockResolvedValue({ accessToken: 'access-token' } as LoginResponseDto);
|
||||
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'aDmIn@iMmIcH.ApP', password: 'password' });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(service.login).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'admin@immich.app' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept an email with a local domain', async () => {
|
||||
service.login.mockResolvedValue({ accessToken: 'access-token' } as LoginResponseDto);
|
||||
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'admin@local', password: 'password' });
|
||||
|
||||
expect(status).toEqual(201);
|
||||
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything());
|
||||
});
|
||||
|
||||
it('should auth cookies on a secure connection', async () => {
|
||||
const loginResponse = mediumFactory.loginResponse();
|
||||
service.login.mockResolvedValue(loginResponse);
|
||||
const { status, body, headers } = await request(ctx.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ name: 'admin', email: 'admin@local', password: 'password' });
|
||||
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual(loginResponse);
|
||||
|
||||
const cookies = headers['set-cookie'];
|
||||
expect(cookies).toHaveLength(3);
|
||||
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
|
||||
`immich_access_token=${loginResponse.accessToken}`,
|
||||
'Max-Age=34560000',
|
||||
'Path=/',
|
||||
expect.stringContaining('Expires='),
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
]);
|
||||
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
|
||||
'immich_auth_type=password',
|
||||
'Max-Age=34560000',
|
||||
'Path=/',
|
||||
expect.stringContaining('Expires='),
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
]);
|
||||
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
|
||||
'immich_is_authenticated=true',
|
||||
'Max-Age=34560000',
|
||||
'Path=/',
|
||||
expect.stringContaining('Expires='),
|
||||
'SameSite=Lax',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/auth/logout');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/change-password', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/auth/change-password')
|
||||
.send({ password: 'password', newPassword: 'Password1234', invalidateSessions: false });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject 5 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||
});
|
||||
|
||||
it('should reject 7 digits', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||
});
|
||||
|
||||
it('should reject non-numbers', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /auth/pin-code', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/status', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/auth/status');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
LoginCredentialDto,
|
||||
LoginResponseDto,
|
||||
LogoutResponseDto,
|
||||
PinCodeChangeDto,
|
||||
PinCodeResetDto,
|
||||
PinCodeSetupDto,
|
||||
SessionUnlockDto,
|
||||
SignUpDto,
|
||||
ValidateAccessTokenResponseDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
@@ -97,7 +93,6 @@ export class AuthController {
|
||||
@Auth() auth: AuthDto,
|
||||
): Promise<LogoutResponseDto> {
|
||||
const authType = (request.cookies || {})[ImmichCookie.AuthType];
|
||||
|
||||
const body = await this.service.logout(auth, authType);
|
||||
return respondWithoutCookie(res, body, [
|
||||
ImmichCookie.AccessToken,
|
||||
@@ -110,71 +105,10 @@ export class AuthController {
|
||||
@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.',
|
||||
description: 'Get information about the current session.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
|
||||
return this.service.getAuthStatus(auth);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
DatabaseBackupDeleteDto,
|
||||
DatabaseBackupListResponseDto,
|
||||
DatabaseBackupUploadDto,
|
||||
} from 'src/dtos/database-backup.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.DatabaseBackups)
|
||||
@Controller('admin/database-backups')
|
||||
export class DatabaseBackupController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: DatabaseBackupService,
|
||||
private maintenanceService: MaintenanceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Endpoint({
|
||||
summary: 'List database backups',
|
||||
description: 'Get the list of the successful and failed backups',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
return this.service.listBackups();
|
||||
}
|
||||
|
||||
@Get(':filename')
|
||||
@FileResponse()
|
||||
@Endpoint({
|
||||
summary: 'Download database backup',
|
||||
description: 'Downloads the database backup file',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDownload, admin: true })
|
||||
async downloadDatabaseBackup(
|
||||
@Param() { filename }: FilenameParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): Promise<void> {
|
||||
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Endpoint({
|
||||
summary: 'Delete database backup',
|
||||
description: 'Delete a backup by its filename',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDelete, admin: true })
|
||||
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||
return this.service.deleteBackup(dto.backups);
|
||||
}
|
||||
|
||||
@Post('start-restore')
|
||||
@Endpoint({
|
||||
summary: 'Start database backup restore flow',
|
||||
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
async startDatabaseRestoreFlow(
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
const { jwt } = await this.maintenanceService.startRestoreFlow();
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@Authenticated({ permission: Permission.BackupUpload, admin: true })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'Backup Upload', type: DatabaseBackupUploadDto })
|
||||
@Endpoint({
|
||||
summary: 'Upload database backup',
|
||||
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(DownloadController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(DownloadService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(DownloadController, [{ provide: DownloadService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /download/info', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/download/info')
|
||||
.send({ assetIds: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /download/archive', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
const stream = new Readable({
|
||||
read() {
|
||||
this.push('test');
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
service.downloadArchive.mockResolvedValue({ stream });
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/download/archive')
|
||||
.send({ assetIds: [factory.uuid()] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
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 { 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(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);
|
||||
}
|
||||
|
||||
@Post('archive')
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 { 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(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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,
|
||||
AssetFaceDeleteDto,
|
||||
AssetFaceResponseDto,
|
||||
FaceDto,
|
||||
PersonResponseDto,
|
||||
} from 'src/dtos/person.dto';
|
||||
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(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,
|
||||
@Body() dto: FaceDto,
|
||||
): Promise<PersonResponseDto> {
|
||||
return this.service.reassignFacesById(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,15 @@
|
||||
import { ActivityController } from 'src/controllers/activity.controller';
|
||||
import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
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';
|
||||
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';
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { StackController } from 'src/controllers/stack.controller';
|
||||
import { SyncController } from 'src/controllers/sync.controller';
|
||||
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
||||
import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
|
||||
import { TagController } from 'src/controllers/tag.controller';
|
||||
import { TimelineController } from 'src/controllers/timeline.controller';
|
||||
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,
|
||||
ActivityController,
|
||||
AlbumController,
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetMediaController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DatabaseBackupController,
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MaintenanceController,
|
||||
MapController,
|
||||
MemoryController,
|
||||
NotificationController,
|
||||
NotificationAdminController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
PluginController,
|
||||
QueueController,
|
||||
SearchController,
|
||||
ServerController,
|
||||
SessionController,
|
||||
SharedLinkController,
|
||||
StackController,
|
||||
SyncController,
|
||||
SystemConfigController,
|
||||
SystemMetadataController,
|
||||
TagController,
|
||||
TimelineController,
|
||||
TrashController,
|
||||
UserAdminController,
|
||||
UserController,
|
||||
ViewController,
|
||||
WorkflowController,
|
||||
];
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Body, Controller, 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 { 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(ApiTag.Jobs)
|
||||
@Controller('jobs')
|
||||
export class JobController {
|
||||
constructor(
|
||||
private service: JobService,
|
||||
private queueService: QueueService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.JobRead, admin: true })
|
||||
@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(':name')
|
||||
@Authenticated({ permission: Permission.JobCreate, admin: true })
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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,
|
||||
LibraryStatsResponseDto,
|
||||
UpdateLibraryDto,
|
||||
ValidateLibraryDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from 'src/dtos/library.dto';
|
||||
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(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||
import { MaintenanceAction } from 'src/enum';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(MaintenanceController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(MaintenanceService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(MaintenanceController, [{ provide: MaintenanceService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /admin/maintenance', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/maintenance').send();
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a backup file when action is restore', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/admin/maintenance').send({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['restoreBackupFilename must be a string', 'restoreBackupFilename should not be empty']),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { BadRequestException, Body, Controller, Get, 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,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceLoginDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
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) {}
|
||||
|
||||
@Get('status')
|
||||
@Endpoint({
|
||||
summary: 'Get maintenance mode status',
|
||||
description: 'Fetch information about the currently running maintenance action.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||
return this.service.getMaintenanceStatus();
|
||||
}
|
||||
|
||||
@Get('detect-install')
|
||||
@Endpoint({
|
||||
summary: 'Detect existing install',
|
||||
description: 'Collect integrity checks and other heuristics about local data.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return this.service.detectPriorInstall();
|
||||
}
|
||||
|
||||
@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.End) {
|
||||
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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,
|
||||
MapMarkerResponseDto,
|
||||
MapReverseGeocodeDto,
|
||||
MapReverseGeocodeResponseDto,
|
||||
} from 'src/dtos/map.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
|
||||
@ApiTags(ApiTag.Map)
|
||||
@Controller('map')
|
||||
export class MapController {
|
||||
constructor(private service: MapService) {}
|
||||
|
||||
@Get('markers')
|
||||
@Authenticated({ permission: Permission.MapRead })
|
||||
@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);
|
||||
}
|
||||
|
||||
@Get('reverse-geocode')
|
||||
@Authenticated({ permission: Permission.MapSearch })
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { MemoryController } from 'src/controllers/memory.controller';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(MemoryController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(MemoryService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(MemoryController, [{ provide: MemoryService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /memories', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
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', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/memories');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate data when type is on this day', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
.send({
|
||||
type: 'on_this_day',
|
||||
data: {},
|
||||
memoryAt: new Date(2021).toISOString(),
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/memories/statistics');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /memories/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /memories/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /memories/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/memories/${factory.uuid()}/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid asset id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/memories/${factory.uuid()}/assets`)
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
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 {
|
||||
MemoryCreateDto,
|
||||
MemoryResponseDto,
|
||||
MemorySearchDto,
|
||||
MemoryStatisticsResponseDto,
|
||||
MemoryUpdateDto,
|
||||
} from 'src/dtos/memory.dto';
|
||||
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(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,
|
||||
@Body() dto: MemoryUpdateDto,
|
||||
): Promise<MemoryResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: BulkIdsDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.addAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.removeAssets(auth, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
|
||||
import { NotificationAdminService } from 'src/services/notification-admin.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(NotificationAdminController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(NotificationAdminService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(NotificationAdminController, [
|
||||
{ provide: NotificationAdminService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /admin/notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post(`/admin/notifications`)
|
||||
.send({ title: 'Test', userId: factory.uuid(), readAt: null });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
NotificationDto,
|
||||
TemplateDto,
|
||||
TemplateResponseDto,
|
||||
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(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: TemplateDto,
|
||||
): Promise<TemplateResponseDto> {
|
||||
return this.service.getTemplate(name, dto.template);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { NotificationController } from 'src/controllers/notification.controller';
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(NotificationController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(NotificationService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(NotificationController, [{ provide: NotificationService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should reject an invalid notification level`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/notifications`)
|
||||
.query({ level: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('ids', () => {
|
||||
it('should require a list', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['ids must be an array'])));
|
||||
});
|
||||
|
||||
it('should require uuids', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [true] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
|
||||
it('should accept valid uuids', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [id] });
|
||||
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ ids: [id] }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /notifications/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/notifications/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /notifications/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ readAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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,
|
||||
NotificationDto,
|
||||
NotificationSearchDto,
|
||||
NotificationUpdateAllDto,
|
||||
NotificationUpdateDto,
|
||||
} from 'src/dtos/notification.dto';
|
||||
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(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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: NotificationUpdateDto,
|
||||
): Promise<NotificationDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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,
|
||||
OAuthAuthorizeResponseDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
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(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),
|
||||
statusCode: HttpStatus.TEMPORARY_REDIRECT,
|
||||
};
|
||||
}
|
||||
|
||||
@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,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<OAuthAuthorizeResponseDto> {
|
||||
const { url, state, codeVerifier } = await this.service.authorize(dto);
|
||||
return respondWithCookie(
|
||||
res,
|
||||
{ url },
|
||||
{
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
{ key: ImmichCookie.OAuthState, value: state },
|
||||
{ key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier },
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const body = await this.service.callback(dto, request.headers, loginDetails);
|
||||
res.clearCookie(ImmichCookie.OAuthState);
|
||||
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
||||
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
|
||||
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.link(auth, dto, request.headers);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PartnerController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(PartnerService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(PartnerController, [
|
||||
{ provide: PartnerService, useValue: service },
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require a direction`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'direction should not be empty',
|
||||
expect.stringContaining('direction must be one of the following values:'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require direction to be an enum`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/partners`)
|
||||
.query({ direction: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require sharedWithId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/partners`)
|
||||
.send({ sharedWithId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/partners/invalid`)
|
||||
.send({ inTimeline: true })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/partners/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
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 { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
|
||||
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(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')
|
||||
@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 });
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: PartnerUpdateDto,
|
||||
): Promise<PartnerResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { PersonController } from 'src/controllers/person.controller';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PersonController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(PersonService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(PersonController, [
|
||||
{ provide: PersonService, useValue: service },
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require closestPersonId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/people`)
|
||||
.query({ closestPersonId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
|
||||
it(`should require closestAssetId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/people`)
|
||||
.query({ closestAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should map an empty color to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/people');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require uuids in the body', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/people')
|
||||
.send({ ids: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.delete(`/people`)
|
||||
.send({ ids: [factory.uuid()] });
|
||||
expect(status).toBe(204);
|
||||
expect(service.deleteAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
|
||||
});
|
||||
|
||||
it(`should not allow a null name`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/people`)
|
||||
.send({ name: null })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['name must be a string']));
|
||||
});
|
||||
|
||||
it(`should require featureFaceAssetId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ featureFaceAssetId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['featureFaceAssetId must be a UUID']));
|
||||
});
|
||||
|
||||
it(`should require isFavorite to be a boolean`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ isFavorite: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
|
||||
it(`should require isHidden to be a boolean`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ isHidden: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isHidden must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should map an empty birthDate to null', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (false)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: false });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'birthDate must be a string in the format yyyy-MM-dd',
|
||||
'Birth date cannot be in the future',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not accept an invalid birth date (number)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: 123_456 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'birthDate must be a string in the format yyyy-MM-dd',
|
||||
'Birth date cannot be in the future',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not accept a birth date in the future)', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/people/${factory.uuid()}`)
|
||||
.send({ birthDate: '9999-01-01' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['Birth date cannot be in the future']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
|
||||
it('should respond with 204', async () => {
|
||||
const { status } = await request(ctx.getHttpServer()).delete(`/people/${factory.uuid()}`);
|
||||
expect(status).toBe(204);
|
||||
expect(service.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /people/:id/merge', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /people/:id/statistics', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
} 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 {
|
||||
AssetFaceUpdateDto,
|
||||
MergePersonDto,
|
||||
PeopleResponseDto,
|
||||
PeopleUpdateDto,
|
||||
PersonCreateDto,
|
||||
PersonResponseDto,
|
||||
PersonSearchDto,
|
||||
PersonStatisticsResponseDto,
|
||||
PersonUpdateDto,
|
||||
} from 'src/dtos/person.dto';
|
||||
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(ApiTag.People)
|
||||
@Controller('people')
|
||||
export class PersonController {
|
||||
constructor(
|
||||
private service: PersonService,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(PersonController.name);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: PersonUpdateDto,
|
||||
): Promise<PersonResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.getThumbnail(auth, id), this.logger);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: AssetFaceUpdateDto,
|
||||
): Promise<PersonResponseDto[]> {
|
||||
return this.service.reassignFaces(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: MergePersonDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.mergePerson(auth, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { PluginResponseDto, PluginTriggerResponseDto } 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('triggers')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugin triggers',
|
||||
description: 'Retrieve a list of all available plugin triggers.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPluginTriggers(): PluginTriggerResponseDto[] {
|
||||
return this.service.getTriggers();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { SearchController } from 'src/controllers/search.controller';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SearchController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(SearchService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SearchController, [{ provide: SearchService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /search/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/metadata');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject page as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1', 'page must be an integer number']));
|
||||
});
|
||||
|
||||
it('should reject page as a negative number', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1']));
|
||||
});
|
||||
|
||||
it('should reject page as 0', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['page must not be less than 1']));
|
||||
});
|
||||
|
||||
it('should reject size as a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'size must not be greater than 1000',
|
||||
'size must not be less than 1',
|
||||
'size must be an integer number',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid size', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['size must not be less than 1', 'size must be an integer number']));
|
||||
});
|
||||
|
||||
it('should reject an visibility as not an enum', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ visibility: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an isFavorite as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ isFavorite: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject an isEncoded as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ isEncoded: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isEncoded must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject an isOffline as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ isOffline: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isOffline must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject an isMotion as not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['isMotion must be a boolean value']));
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/random');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject if withStacked is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/random')
|
||||
.send({ withStacked: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should reject if withPeople is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/random')
|
||||
.send({ withPeople: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/smart', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/search/smart');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/explore', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/explore');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/person', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/person');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/places', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/places');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a name', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/cities', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/cities');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/suggestions', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/search/suggestions');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a type', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'type should not be empty',
|
||||
expect.stringContaining('type must be one of the following values:'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
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';
|
||||
import {
|
||||
LargeAssetSearchDto,
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
SearchExploreResponseDto,
|
||||
SearchPeopleDto,
|
||||
SearchPlacesDto,
|
||||
SearchResponseDto,
|
||||
SearchStatisticsResponseDto,
|
||||
SearchSuggestionRequestDto,
|
||||
SmartSearchDto,
|
||||
StatisticsSearchDto,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
|
||||
@ApiTags(ApiTag.Search)
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private service: SearchService) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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[]>;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ServerController } from 'src/controllers/server.controller';
|
||||
import { ServerService } from 'src/services/server.service';
|
||||
import { SystemMetadataService } from 'src/services/system-metadata.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(ServerController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const serverService = mockBaseService(ServerService);
|
||||
const systemMetadataService = mockBaseService(SystemMetadataService);
|
||||
const versionService = mockBaseService(VersionService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(ServerController, [
|
||||
{ provide: ServerService, useValue: serverService },
|
||||
{ provide: SystemMetadataService, useValue: systemMetadataService },
|
||||
{ provide: VersionService, useValue: versionService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
serverService.resetAllMocks();
|
||||
versionService.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/server/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,21 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common';
|
||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import {
|
||||
ServerAboutResponseDto,
|
||||
ServerApkLinksDto,
|
||||
ServerConfigDto,
|
||||
ServerFeaturesDto,
|
||||
ServerMediaTypesResponseDto,
|
||||
ServerPingResponse,
|
||||
ServerStatsResponseDto,
|
||||
ServerStorageResponseDto,
|
||||
ServerThemeDto,
|
||||
ServerVersionHistoryResponseDto,
|
||||
ServerVersionResponseDto,
|
||||
} from 'src/dtos/server.dto';
|
||||
import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto';
|
||||
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(ApiTag.Server)
|
||||
@Controller('server')
|
||||
export class ServerController {
|
||||
constructor(
|
||||
private service: ServerService,
|
||||
private systemMetadataService: SystemMetadataService,
|
||||
private versionService: VersionService,
|
||||
) {}
|
||||
constructor(private service: ServerService) {}
|
||||
|
||||
@Get('about')
|
||||
@Authenticated({ permission: Permission.ServerAbout })
|
||||
@@ -42,28 +28,6 @@ export class ServerController {
|
||||
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',
|
||||
@@ -81,17 +45,7 @@ export class ServerController {
|
||||
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();
|
||||
return this.service.getVersion();
|
||||
}
|
||||
|
||||
@Get('features')
|
||||
@@ -104,16 +58,6 @@ export class ServerController {
|
||||
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',
|
||||
@@ -123,71 +67,4 @@ export class ServerController {
|
||||
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();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, SessionUpdateDto } from 'src/dtos/session.dto';
|
||||
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
@@ -47,21 +47,6 @@ export class SessionController {
|
||||
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,
|
||||
@Body() dto: SessionUpdateDto,
|
||||
): Promise<SessionResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.SessionDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@@ -73,16 +58,4 @@ export class SessionController {
|
||||
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SharedLinkController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(SharedLinkService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SharedLinkController, [{ provide: SharedLinkService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /shared-links', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/shared-links');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow an null expiresAt', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/shared-links')
|
||||
.send({ expiresAt: null, type: SharedLinkType.Individual });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} 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';
|
||||
import {
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
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(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,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
|
||||
if (sharedLinkToken) {
|
||||
dto.token = sharedLinkToken;
|
||||
}
|
||||
const body = await this.service.getMine(auth, dto);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: SharedLinkEditDto,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: AssetIdsDto,
|
||||
): Promise<AssetIdsResponseDto[]> {
|
||||
return this.service.addAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: AssetIdsDto,
|
||||
): Promise<AssetIdsResponseDto[]> {
|
||||
return this.service.removeAssets(auth, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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 { 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(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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: StackUpdateDto,
|
||||
): Promise<StackResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { SyncController } from 'src/controllers/sync.controller';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SyncController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const syncService = mockBaseService(SyncService);
|
||||
const errorService = { handleError: vi.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SyncController, [
|
||||
{ provide: SyncService, useValue: syncService },
|
||||
{ provide: GlobalExceptionFilter, useValue: errorService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
syncService.resetAllMocks();
|
||||
errorService.handleError.mockReset();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /sync/stream', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/sync/stream');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require sync request type enums', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/sync/stream')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow more than 1,000 entries', async () => {
|
||||
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['acks must contain no more than 1000 elements']));
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /sync/ack', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/sync/ack');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require sync response type enums', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/sync/ack')
|
||||
.send({ types: ['invalid'] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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 {
|
||||
AssetDeltaSyncDto,
|
||||
AssetDeltaSyncResponseDto,
|
||||
AssetFullSyncDto,
|
||||
SyncAckDeleteDto,
|
||||
SyncAckDto,
|
||||
SyncAckSetDto,
|
||||
SyncStreamDto,
|
||||
} from 'src/dtos/sync.dto';
|
||||
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(ApiTag.Sync)
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(
|
||||
private service: SyncService,
|
||||
private errorService: GlobalExceptionFilter,
|
||||
) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Post('stream')
|
||||
@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);
|
||||
} catch (error: Error | any) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
this.errorService.handleError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,102 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { defaults } from 'src/config';
|
||||
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SystemConfigController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const systemConfigService = mockBaseService(SystemConfigService);
|
||||
const templateService = mockBaseService(StorageTemplateService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SystemConfigController, [
|
||||
{ provide: SystemConfigService, useValue: systemConfigService },
|
||||
{ provide: StorageTemplateService, useValue: templateService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
systemConfigService.resetAllMocks();
|
||||
templateService.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /system-config', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/system-config');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /system-config/defaults', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/system-config/defaults');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /system-config', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/system-config');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('nightlyTasks', () => {
|
||||
it('should validate nightly jobs start time', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.nightlyTasks.startTime = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.startTime must be in HH:mm format']));
|
||||
});
|
||||
|
||||
it('should accept a valid time', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.nightlyTasks.startTime = '05:05';
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should validate a boolean field', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
(config.nightlyTasks.databaseCleanup as any) = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('should accept config without optional progressive property', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
delete config.image.thumbnail.progressive;
|
||||
delete config.image.preview.progressive;
|
||||
delete config.image.fullsize.progressive;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept config with progressive set to true', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.image.thumbnail.progressive = true;
|
||||
config.image.preview.progressive = true;
|
||||
config.image.fullsize.progressive = true;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should reject invalid progressive value', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
(config.image.thumbnail.progressive as any) = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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 { 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(ApiTag.SystemConfig)
|
||||
@Controller('system-config')
|
||||
export class SystemConfigController {
|
||||
constructor(
|
||||
private service: SystemConfigService,
|
||||
private storageTemplateService: StorageTemplateService,
|
||||
) {}
|
||||
|
||||
@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,62 +0,0 @@
|
||||
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 { ApiTag, Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { SystemMetadataService } from 'src/services/system-metadata.service';
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TagController } from 'src/controllers/tag.controller';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(TagController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(TagService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(TagController, [{ provide: TagService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should a null parentId', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow setting a null color via an empty string', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
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 {
|
||||
TagBulkAssetsDto,
|
||||
TagBulkAssetsResponseDto,
|
||||
TagCreateDto,
|
||||
TagResponseDto,
|
||||
TagUpdateDto,
|
||||
TagUpsertDto,
|
||||
} from 'src/dtos/tag.dto';
|
||||
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(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);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: BulkIdsDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.addAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.removeAssets(auth, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { TimelineController } from 'src/controllers/timeline.controller';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(TimelineController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(TimelineService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(TimelineController, [{ provide: TimelineService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /timeline/buckets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/timeline/buckets');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /timeline/bucket', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/timeline/bucket?timeBucket=1900-01-01');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// TODO enable date string validation while still accepting 5 digit years
|
||||
it.fails('should fail if time bucket is invalid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/bucket').query({ timeBucket: 'foo' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Invalid time bucket format'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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 { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Get('bucket')
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
|
||||
@ApiTags(ApiTag.Trash)
|
||||
@Controller('trash')
|
||||
export class TrashController {
|
||||
constructor(private service: TrashService) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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,134 +0,0 @@
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserAdminCreateDto } from 'src/dtos/user.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(UserAdminController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(UserAdminService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(UserAdminController, [
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
{ provide: UserAdminService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/admin/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a null pinCode', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/admin/users`).send({
|
||||
name: 'Test user',
|
||||
email: 'test@immich.cloud',
|
||||
password: 'password',
|
||||
pinCode: null,
|
||||
});
|
||||
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ pinCode: null }));
|
||||
});
|
||||
|
||||
it('should allow a null avatarColor', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/admin/users`).send({
|
||||
name: 'Test user',
|
||||
email: 'test@immich.cloud',
|
||||
password: 'password',
|
||||
avatarColor: null,
|
||||
});
|
||||
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
|
||||
it(`should `, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
email: 'user@immich.app',
|
||||
password: 'test',
|
||||
name: 'Test User',
|
||||
quotaSizeInBytes: 1.2,
|
||||
};
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
email: 'user@immich.app',
|
||||
password: 'test',
|
||||
name: 'Test User',
|
||||
quotaSizeInBytes: 1.2,
|
||||
};
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/admin/users/${factory.uuid()}`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ quotaSizeInBytes: 1.2 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
|
||||
it('should allow a null pinCode', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ pinCode: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ pinCode: null }));
|
||||
});
|
||||
|
||||
it('should allow a null avatarColor', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
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';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
UserAdminSearchDto,
|
||||
UserAdminUpdateDto,
|
||||
} from 'src/dtos/user.dto';
|
||||
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(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,
|
||||
@Body() dto: UserAdminUpdateDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: UserAdminDeleteDto,
|
||||
): Promise<UserAdminResponseDto> {
|
||||
return this.service.delete(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Query() dto: AssetStatsDto,
|
||||
): Promise<AssetStatsResponseDto> {
|
||||
return this.service.getStatistics(auth, id, dto);
|
||||
}
|
||||
|
||||
@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,
|
||||
@Body() dto: UserPreferencesUpdateDto,
|
||||
): Promise<UserPreferencesResponseDto> {
|
||||
return this.service.updatePreferences(auth, id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(UserController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(UserService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(UserController, [
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
{ provide: UserService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/users/me');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/users/me');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const key of ['email', 'name']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should allow an empty avatarColor', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ avatarColor: null });
|
||||
expect(service.updateMe).toHaveBeenCalledWith(undefined, expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/users/me/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/me/license', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/users/me/license');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,17 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { 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 { 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';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Users)
|
||||
@Controller(RouteKey.User)
|
||||
export class UserController {
|
||||
constructor(
|
||||
private service: UserService,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
constructor(private service: UserService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.UserRead })
|
||||
@@ -64,106 +39,13 @@ export class UserController {
|
||||
@Authenticated({ permission: Permission.UserUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Update current user',
|
||||
description: 'Update the current user making teh API request.',
|
||||
description: 'Update the current user making the API request.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
return this.service.updateMe(auth, dto);
|
||||
}
|
||||
|
||||
@Get('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,
|
||||
): Promise<UserPreferencesResponseDto> {
|
||||
return this.service.updateMyPreferences(auth, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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({
|
||||
@@ -174,45 +56,4 @@ export class UserController {
|
||||
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
return this.service.get(id);
|
||||
}
|
||||
|
||||
@Post('profile-image')
|
||||
@Authenticated({ permission: Permission.UserProfileImageUpdate })
|
||||
@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,
|
||||
): Promise<CreateProfileImageResponseDto> {
|
||||
return this.service.createProfileImage(auth, fileInfo);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
|
||||
@ApiTags(ApiTag.Views)
|
||||
@Controller('view')
|
||||
export class ViewController {
|
||||
constructor(private service: ViewService) {}
|
||||
|
||||
@Get('folder/unique-paths')
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@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({ permission: Permission.FolderRead })
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
vitest.mock('src/constants', () => ({
|
||||
IWorker: 'IWorker',
|
||||
}));
|
||||
|
||||
describe('StorageCore', () => {
|
||||
describe('isImmichPath', () => {
|
||||
beforeAll(() => {
|
||||
StorageCore.setMediaLocation('/photos');
|
||||
});
|
||||
|
||||
it('should return true for APP_MEDIA_LOCATION path', () => {
|
||||
const immichPath = '/photos';
|
||||
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for paths within the APP_MEDIA_LOCATION', () => {
|
||||
const immichPath = '/photos/new/';
|
||||
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for paths outside the APP_MEDIA_LOCATION and same starts', () => {
|
||||
const nonImmichPath = '/photos_new';
|
||||
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for paths outside the APP_MEDIA_LOCATION', () => {
|
||||
const nonImmichPath = '/some/other/path';
|
||||
expect(StorageCore.isImmichPath(nonImmichPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { StorageAsset } from 'src/database';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
ImageFormat,
|
||||
PathType,
|
||||
PersonPathType,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MoveRepository } from 'src/repositories/move.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
export interface MoveRequest {
|
||||
entityId: string;
|
||||
pathType: PathType;
|
||||
oldPath: string | null;
|
||||
newPath: string;
|
||||
assetInfo?: {
|
||||
sizeInBytes: number;
|
||||
checksum: Buffer;
|
||||
};
|
||||
}
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
let mediaLocation: string | undefined;
|
||||
|
||||
export class StorageCore {
|
||||
private constructor(
|
||||
private assetRepository: AssetRepository,
|
||||
private configRepository: ConfigRepository,
|
||||
private cryptoRepository: CryptoRepository,
|
||||
private moveRepository: MoveRepository,
|
||||
private personRepository: PersonRepository,
|
||||
private storageRepository: StorageRepository,
|
||||
private systemMetadataRepository: SystemMetadataRepository,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(StorageCore.name);
|
||||
}
|
||||
|
||||
static create(
|
||||
assetRepository: AssetRepository,
|
||||
configRepository: ConfigRepository,
|
||||
cryptoRepository: CryptoRepository,
|
||||
moveRepository: MoveRepository,
|
||||
personRepository: PersonRepository,
|
||||
storageRepository: StorageRepository,
|
||||
systemMetadataRepository: SystemMetadataRepository,
|
||||
logger: LoggingRepository,
|
||||
) {
|
||||
if (!instance) {
|
||||
instance = new StorageCore(
|
||||
assetRepository,
|
||||
configRepository,
|
||||
cryptoRepository,
|
||||
moveRepository,
|
||||
personRepository,
|
||||
storageRepository,
|
||||
systemMetadataRepository,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
static getMediaLocation(): string {
|
||||
if (mediaLocation === undefined) {
|
||||
throw new Error('Media location is not set.');
|
||||
}
|
||||
|
||||
return mediaLocation;
|
||||
}
|
||||
|
||||
static setMediaLocation(location: string) {
|
||||
mediaLocation = location;
|
||||
}
|
||||
|
||||
static getFolderLocation(folder: StorageFolder, userId: string) {
|
||||
return join(StorageCore.getBaseFolder(folder), userId);
|
||||
}
|
||||
|
||||
static getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
||||
return join(StorageCore.getBaseFolder(StorageFolder.Library), user.storageLabel || user.id);
|
||||
}
|
||||
|
||||
static getBaseFolder(folder: StorageFolder) {
|
||||
return join(StorageCore.getMediaLocation(), folder);
|
||||
}
|
||||
|
||||
static getPersonThumbnailPath(person: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
|
||||
);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`);
|
||||
}
|
||||
|
||||
static isAndroidMotionPath(originalPath: string) {
|
||||
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.EncodedVideo));
|
||||
}
|
||||
|
||||
static isImmichPath(path: string) {
|
||||
const resolvedPath = resolve(path);
|
||||
const resolvedAppMediaLocation = StorageCore.getMediaLocation();
|
||||
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
|
||||
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
|
||||
? resolvedAppMediaLocation
|
||||
: resolvedAppMediaLocation + '/';
|
||||
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
||||
}
|
||||
|
||||
async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
|
||||
const { id: entityId, files } = asset;
|
||||
const oldFile = getAssetFile(files, fileType, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType: fileType,
|
||||
oldPath: oldFile?.path || null,
|
||||
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
|
||||
});
|
||||
}
|
||||
|
||||
async moveAssetVideo(asset: StorageAsset) {
|
||||
return this.moveFile({
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.EncodedVideo,
|
||||
oldPath: asset.encodedVideoPath,
|
||||
newPath: StorageCore.getEncodedVideoPath(asset),
|
||||
});
|
||||
}
|
||||
|
||||
async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) {
|
||||
const { id: entityId, thumbnailPath } = person;
|
||||
switch (pathType) {
|
||||
case PersonPathType.Face: {
|
||||
await this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
oldPath: thumbnailPath,
|
||||
newPath: StorageCore.getPersonThumbnailPath(person),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async moveFile(request: MoveRequest) {
|
||||
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
||||
if (!oldPath || oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureFolders(newPath);
|
||||
|
||||
let move = await this.moveRepository.getByEntity(entityId, pathType);
|
||||
if (move) {
|
||||
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
|
||||
const oldPathExists = await this.storageRepository.checkFileExists(move.oldPath);
|
||||
const newPathExists = await this.storageRepository.checkFileExists(move.newPath);
|
||||
const newPathCheck = newPathExists ? move.newPath : null;
|
||||
const actualPath = oldPathExists ? move.oldPath : newPathCheck;
|
||||
if (!actualPath) {
|
||||
this.logger.warn('Unable to complete move. File does not exist at either location.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileAtNewLocation = actualPath === move.newPath;
|
||||
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
|
||||
|
||||
if (
|
||||
fileAtNewLocation &&
|
||||
!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))
|
||||
) {
|
||||
this.logger.fatal(
|
||||
`Skipping move as file verification failed, old file is missing and new file is different to what was expected`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
move = await this.moveRepository.update(move.id, { id: move.id, oldPath: actualPath, newPath });
|
||||
} else {
|
||||
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
|
||||
}
|
||||
|
||||
if (pathType === AssetPathType.Original && !assetInfo) {
|
||||
this.logger.warn(`Unable to complete move. Missing asset info for ${entityId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (move.oldPath !== newPath) {
|
||||
try {
|
||||
this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`);
|
||||
await this.storageRepository.rename(move.oldPath, newPath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'EXDEV') {
|
||||
this.logger.warn(
|
||||
`Unable to complete move. Error renaming file with code ${error.code} and message: ${error.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Unable to rename file. Falling back to copy, verify and delete`);
|
||||
await this.storageRepository.copyFile(move.oldPath, newPath);
|
||||
|
||||
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, newPath, assetInfo))) {
|
||||
this.logger.warn(`Skipping move due to file size mismatch`);
|
||||
await this.storageRepository.unlink(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const { atime, mtime } = await this.storageRepository.stat(move.oldPath);
|
||||
await this.storageRepository.utimes(newPath, atime, mtime);
|
||||
|
||||
try {
|
||||
await this.storageRepository.unlink(move.oldPath);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.savePath(pathType, entityId, newPath);
|
||||
await this.moveRepository.delete(move.id);
|
||||
}
|
||||
|
||||
private async verifyNewPathContentsMatchesExpected(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
assetInfo?: { sizeInBytes: number; checksum: Buffer },
|
||||
) {
|
||||
const oldStat = await this.storageRepository.stat(oldPath);
|
||||
const newStat = await this.storageRepository.stat(newPath);
|
||||
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : oldStat.size;
|
||||
const newPathSize = newStat.size;
|
||||
this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`);
|
||||
if (newPathSize !== oldPathSize) {
|
||||
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
|
||||
return false;
|
||||
}
|
||||
const repos = {
|
||||
configRepo: this.configRepository,
|
||||
metadataRepo: this.systemMetadataRepository,
|
||||
logger: this.logger,
|
||||
};
|
||||
const config = await getConfig(repos, { withCache: true });
|
||||
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
|
||||
const { checksum } = assetInfo;
|
||||
const newChecksum = await this.cryptoRepository.hashFile(newPath);
|
||||
if (!newChecksum.equals(checksum)) {
|
||||
this.logger.warn(
|
||||
`Unable to complete move. File checksum mismatch: ${newChecksum.toString('base64')} !== ${checksum.toString(
|
||||
'base64',
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.logger.debug(`File checksum check: ${newChecksum.toString('base64')} === ${checksum.toString('base64')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureFolders(input: string) {
|
||||
this.storageRepository.mkdirSync(dirname(input));
|
||||
}
|
||||
|
||||
removeEmptyDirs(folder: StorageFolder) {
|
||||
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetFileType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetFileType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetFileType.Thumbnail: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
||||
}
|
||||
case AssetPathType.EncodedVideo: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
case AssetFileType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
}
|
||||
case PersonPathType.Face: {
|
||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(StorageCore.getFolderLocation(folder, ownerId), filename.slice(0, 2), filename.slice(2, 4));
|
||||
}
|
||||
|
||||
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(this.getNestedFolder(folder, ownerId, filename), filename);
|
||||
}
|
||||
|
||||
static getTempPathInDir(dir: string): string {
|
||||
return join(dir, `${randomUUID()}.tmp`);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,10 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
MemoryType,
|
||||
Permission,
|
||||
PluginContext,
|
||||
PluginTriggerType,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
UserAvatarColor,
|
||||
UserStatus,
|
||||
} 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';
|
||||
import { Permission, UserStatus } from 'src/enum';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
email: string;
|
||||
quotaUsageInBytes: number;
|
||||
quotaSizeInBytes: number | null;
|
||||
};
|
||||
|
||||
export type AlbumUser = {
|
||||
user: User;
|
||||
role: AlbumUserRole;
|
||||
};
|
||||
|
||||
export type AssetFile = {
|
||||
id: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export type Library = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
name: string;
|
||||
importPaths: string[];
|
||||
exclusionPatterns: string[];
|
||||
deletedAt: Date | null;
|
||||
refreshedAt: Date | null;
|
||||
assets?: MapAsset[];
|
||||
};
|
||||
|
||||
export type AuthApiKey = {
|
||||
@@ -61,19 +12,6 @@ export type AuthApiKey = {
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export type Activity = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
assetId: string | null;
|
||||
comment: string | null;
|
||||
isLiked: boolean;
|
||||
updateId: string;
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -83,119 +21,19 @@ export type ApiKey = {
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
value: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
color: string | null;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
export type Memory = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
memoryAt: Date;
|
||||
seenAt: Date | null;
|
||||
showAt: Date | null;
|
||||
hideAt: Date | null;
|
||||
type: MemoryType;
|
||||
data: object;
|
||||
ownerId: string;
|
||||
isSaved: boolean;
|
||||
assets: MapAsset[];
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
isExternal: boolean;
|
||||
visibility: AssetVisibility;
|
||||
libraryId: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
ownerId: string;
|
||||
type: AssetType;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarColor: UserAvatarColor | null;
|
||||
profileImagePath: string;
|
||||
profileChangedAt: Date;
|
||||
};
|
||||
|
||||
export type UserAdmin = User & {
|
||||
storageLabel: string | null;
|
||||
shouldChangePassword: boolean;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
oauthId: string;
|
||||
quotaSizeInBytes: number | null;
|
||||
quotaUsageInBytes: number;
|
||||
status: UserStatus;
|
||||
metadata: UserMetadataItem[];
|
||||
};
|
||||
|
||||
export type StorageAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
files: AssetFile[];
|
||||
encodedVideoPath: string | null;
|
||||
};
|
||||
|
||||
export type Stack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
owner?: User;
|
||||
ownerId: string;
|
||||
assets: MapAsset[];
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
export type AuthSharedLink = {
|
||||
id: string;
|
||||
expiresAt: Date | null;
|
||||
userId: string;
|
||||
showExif: boolean;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
export type SharedLink = {
|
||||
id: string;
|
||||
album?: Album | null;
|
||||
albumId: string | null;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
assets: MapAsset[];
|
||||
createdAt: Date;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
key: Buffer;
|
||||
password: string | null;
|
||||
showExif: boolean;
|
||||
type: SharedLinkType;
|
||||
userId: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type Album = Selectable<AlbumTable> & {
|
||||
owner: User;
|
||||
assets: MapAsset[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
@@ -203,32 +41,6 @@ export type AuthSession = {
|
||||
hasElevatedPermission: boolean;
|
||||
};
|
||||
|
||||
export type Partner = {
|
||||
sharedById: string;
|
||||
sharedBy: User;
|
||||
sharedWithId: string;
|
||||
sharedWith: User;
|
||||
createdAt: Date;
|
||||
createId: string;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
inTimeline: boolean;
|
||||
};
|
||||
|
||||
export type Place = {
|
||||
admin1Code: string | null;
|
||||
admin1Name: string | null;
|
||||
admin2Code: string | null;
|
||||
admin2Name: string | null;
|
||||
alternateNames: string | null;
|
||||
countryCode: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
modificationDate: Date;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
@@ -241,132 +53,13 @@ export type Session = {
|
||||
isPendingSyncReset: boolean;
|
||||
};
|
||||
|
||||
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | 'lockedProperties'>;
|
||||
|
||||
export type Person = {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isFavorite: boolean;
|
||||
name: string;
|
||||
birthDate: Date | null;
|
||||
color: string | null;
|
||||
faceAssetId: string | null;
|
||||
isHidden: boolean;
|
||||
thumbnailPath: string;
|
||||
};
|
||||
|
||||
export type AssetFace = {
|
||||
id: string;
|
||||
deletedAt: Date | null;
|
||||
assetId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
sourceType: SourceType;
|
||||
person?: Person | null;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
pluginFilterId: string;
|
||||
filterConfig: FilterConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type WorkflowAction = Selectable<WorkflowActionTable> & {
|
||||
workflowId: string;
|
||||
pluginActionId: string;
|
||||
actionConfig: ActionConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
const userWithPrefixColumns = [
|
||||
'user2.id',
|
||||
'user2.name',
|
||||
'user2.email',
|
||||
'user2.avatarColor',
|
||||
'user2.profileImagePath',
|
||||
'user2.profileChangedAt',
|
||||
] as const;
|
||||
const userColumns = ['id', 'name', 'email'] as const;
|
||||
|
||||
export const columns = {
|
||||
asset: [
|
||||
'asset.id',
|
||||
'asset.checksum',
|
||||
'asset.deviceAssetId',
|
||||
'asset.deviceId',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.isExternal',
|
||||
'asset.visibility',
|
||||
'asset.libraryId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.localDateTime',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.type',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
'asset_file.isEdited',
|
||||
'asset_file.isProgressive',
|
||||
],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
],
|
||||
user: userColumns,
|
||||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
...userColumns,
|
||||
'createdAt',
|
||||
@@ -374,120 +67,7 @@ export const columns = {
|
||||
'deletedAt',
|
||||
'isAdmin',
|
||||
'status',
|
||||
'oauthId',
|
||||
'profileImagePath',
|
||||
'shouldChangePassword',
|
||||
'storageLabel',
|
||||
'quotaSizeInBytes',
|
||||
'quotaUsageInBytes',
|
||||
],
|
||||
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
|
||||
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
||||
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
|
||||
syncAsset: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
'asset.originalFileName',
|
||||
'asset.thumbhash',
|
||||
'asset.checksum',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.localDateTime',
|
||||
'asset.type',
|
||||
'asset.deletedAt',
|
||||
'asset.isFavorite',
|
||||
'asset.visibility',
|
||||
'asset.duration',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
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'],
|
||||
syncAssetExif: [
|
||||
'asset_exif.assetId',
|
||||
'asset_exif.description',
|
||||
'asset_exif.exifImageWidth',
|
||||
'asset_exif.exifImageHeight',
|
||||
'asset_exif.fileSizeInByte',
|
||||
'asset_exif.orientation',
|
||||
'asset_exif.dateTimeOriginal',
|
||||
'asset_exif.modifyDate',
|
||||
'asset_exif.timeZone',
|
||||
'asset_exif.latitude',
|
||||
'asset_exif.longitude',
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
'asset_exif.make',
|
||||
'asset_exif.model',
|
||||
'asset_exif.lensModel',
|
||||
'asset_exif.fNumber',
|
||||
'asset_exif.focalLength',
|
||||
'asset_exif.iso',
|
||||
'asset_exif.exposureTime',
|
||||
'asset_exif.profileDescription',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.fps',
|
||||
],
|
||||
exif: [
|
||||
'asset_exif.assetId',
|
||||
'asset_exif.autoStackId',
|
||||
'asset_exif.bitsPerSample',
|
||||
'asset_exif.city',
|
||||
'asset_exif.colorspace',
|
||||
'asset_exif.country',
|
||||
'asset_exif.dateTimeOriginal',
|
||||
'asset_exif.description',
|
||||
'asset_exif.exifImageHeight',
|
||||
'asset_exif.exifImageWidth',
|
||||
'asset_exif.exposureTime',
|
||||
'asset_exif.fileSizeInByte',
|
||||
'asset_exif.fNumber',
|
||||
'asset_exif.focalLength',
|
||||
'asset_exif.fps',
|
||||
'asset_exif.iso',
|
||||
'asset_exif.latitude',
|
||||
'asset_exif.lensModel',
|
||||
'asset_exif.livePhotoCID',
|
||||
'asset_exif.longitude',
|
||||
'asset_exif.make',
|
||||
'asset_exif.model',
|
||||
'asset_exif.modifyDate',
|
||||
'asset_exif.orientation',
|
||||
'asset_exif.profileDescription',
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.state',
|
||||
'asset_exif.tags',
|
||||
'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;
|
||||
|
||||
export type LockableProperty = (typeof lockableProperties)[number];
|
||||
export const lockableProperties = [
|
||||
'description',
|
||||
'dateTimeOriginal',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
'tags',
|
||||
] as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
|
||||
import { ApiCustomExtension, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
|
||||
import { EmitEvent } from 'src/repositories/event.repository';
|
||||
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
|
||||
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
|
||||
@@ -163,7 +163,6 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
|
||||
|
||||
if (history?.isDeprecated()) {
|
||||
options.deprecated = true;
|
||||
decorators.push(ApiTags(ApiTag.Deprecated));
|
||||
}
|
||||
|
||||
decorators.push(ApiOperation({ ...options, ...extensions }));
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
|
||||
import { Activity } from 'src/database';
|
||||
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum ReactionType {
|
||||
COMMENT = 'comment',
|
||||
LIKE = 'like',
|
||||
}
|
||||
|
||||
export enum ReactionLevel {
|
||||
ALBUM = 'album',
|
||||
ASSET = 'asset',
|
||||
}
|
||||
|
||||
export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
|
||||
|
||||
export class ActivityResponseDto {
|
||||
@ApiProperty({ description: 'Activity ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||
createdAt!: Date;
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type' })
|
||||
type!: ReactionType;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
user!: UserResponseDto;
|
||||
@ApiProperty({ description: 'Asset ID (if activity is for an asset)' })
|
||||
assetId!: string | null;
|
||||
@ApiPropertyOptional({ description: 'Comment text (for comment activities)' })
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export class ActivityStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Number of comments' })
|
||||
comments!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of likes' })
|
||||
likes!: number;
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ValidateUUID({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Asset ID (if activity is for an asset)' })
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export class ActivitySearchDto extends ActivityDto {
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Filter by activity type', optional: true })
|
||||
type?: ReactionType;
|
||||
|
||||
@ValidateEnum({ enum: ReactionLevel, name: 'ReactionLevel', description: 'Filter by activity level', optional: true })
|
||||
level?: ReactionLevel;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
|
||||
|
||||
export class ActivityCreateDto extends ActivityDto {
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type (like or comment)' })
|
||||
type!: ReactionType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comment text (required if type is comment)' })
|
||||
@ValidateIf(isComment)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export const mapActivity = (activity: Activity): ActivityResponseDto => {
|
||||
return {
|
||||
id: activity.id,
|
||||
assetId: activity.assetId,
|
||||
createdAt: activity.createdAt,
|
||||
comment: activity.comment,
|
||||
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
|
||||
user: mapUser(activity.user),
|
||||
};
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { mapAlbum } from 'src/dtos/album.dto';
|
||||
import { albumStub } from 'test/fixtures/album.stub';
|
||||
|
||||
describe('mapAlbum', () => {
|
||||
it('should set start and end dates', () => {
|
||||
const dto = mapAlbum(albumStub.twoAssets, false);
|
||||
expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
|
||||
expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
|
||||
});
|
||||
|
||||
it('should not set start and end dates for empty assets', () => {
|
||||
const dto = mapAlbum(albumStub.empty, false);
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||
import _ from 'lodash';
|
||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class AlbumInfoDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Exclude assets from response' })
|
||||
withoutAssets?: boolean;
|
||||
}
|
||||
|
||||
export class AlbumUserAddDto {
|
||||
@ValidateUUID({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ValidateEnum({
|
||||
enum: AlbumUserRole,
|
||||
name: 'AlbumUserRole',
|
||||
description: 'Album user role',
|
||||
default: AlbumUserRole.Editor,
|
||||
})
|
||||
role?: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class AddUsersDto {
|
||||
@ApiProperty({ description: 'Album users to add' })
|
||||
@ArrayNotEmpty()
|
||||
albumUsers!: AlbumUserAddDto[];
|
||||
}
|
||||
|
||||
export class AlbumUserCreateDto {
|
||||
@ValidateUUID({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@ApiProperty({ description: 'Album name' })
|
||||
@IsString()
|
||||
albumName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album description' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album users' })
|
||||
@Optional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AlbumUserCreateDto)
|
||||
albumUsers?: AlbumUserCreateDto[];
|
||||
|
||||
@ValidateUUID({ optional: true, each: true, description: 'Initial asset IDs' })
|
||||
assetIds?: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsDto {
|
||||
@ValidateUUID({ each: true, description: 'Album IDs' })
|
||||
albumIds!: string[];
|
||||
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsResponseDto {
|
||||
@ApiProperty({ description: 'Operation success' })
|
||||
success!: boolean;
|
||||
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', description: 'Error reason', optional: true })
|
||||
error?: BulkIdErrorReason;
|
||||
}
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@ApiPropertyOptional({ description: 'Album name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
albumName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album description' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Album thumbnail asset ID' })
|
||||
albumThumbnailAssetId?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Enable activity feed' })
|
||||
isActivityEnabled?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
|
||||
})
|
||||
shared?: boolean;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Filter albums containing this asset ID (ignores shared parameter)' })
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export class AlbumStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Number of owned albums' })
|
||||
owned!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of shared albums' })
|
||||
shared!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of non-shared albums' })
|
||||
notShared!: number;
|
||||
}
|
||||
|
||||
export class UpdateAlbumUserDto {
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class AlbumUserResponseDto {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
user!: UserResponseDto;
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class ContributorCountResponseDto {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets contributed' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export class AlbumResponseDto {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Album name' })
|
||||
albumName!: string;
|
||||
@ApiProperty({ description: 'Album description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Thumbnail asset ID' })
|
||||
albumThumbnailAssetId!: string | null;
|
||||
@ApiProperty({ description: 'Is shared album' })
|
||||
shared!: boolean;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
albumUsers!: AlbumUserResponseDto[];
|
||||
@ApiProperty({ description: 'Has shared link' })
|
||||
hasSharedLink!: boolean;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
assets!: AssetResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
owner!: UserResponseDto;
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||
assetCount!: number;
|
||||
@ApiPropertyOptional({ description: 'Last modified asset timestamp' })
|
||||
lastModifiedAssetTimestamp?: Date;
|
||||
@ApiPropertyOptional({ description: 'Start date (earliest asset)' })
|
||||
startDate?: Date;
|
||||
@ApiPropertyOptional({ description: 'End date (latest asset)' })
|
||||
endDate?: Date;
|
||||
@ApiProperty({ description: 'Activity feed enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Type(() => ContributorCountResponseDto)
|
||||
contributorCounts?: ContributorCountResponseDto[];
|
||||
}
|
||||
|
||||
export type MapAlbumDto = {
|
||||
albumUsers?: AlbumUser[];
|
||||
assets?: MapAsset[];
|
||||
sharedLinks?: AuthSharedLink[];
|
||||
albumName: string;
|
||||
description: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: User;
|
||||
isActivityEnabled: boolean;
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
for (const albumUser of entity.albumUsers) {
|
||||
const user = mapUser(albumUser.user);
|
||||
albumUsers.push({
|
||||
user,
|
||||
role: albumUser.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']);
|
||||
|
||||
const assets = entity.assets || [];
|
||||
|
||||
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
|
||||
const hasSharedUser = albumUsers.length > 0;
|
||||
|
||||
let startDate = assets.at(0)?.localDateTime;
|
||||
let endDate = assets.at(-1)?.localDateTime;
|
||||
// Swap dates if start date is greater than end date.
|
||||
if (startDate && endDate && startDate > endDate) {
|
||||
[startDate, endDate] = [endDate, startDate];
|
||||
}
|
||||
|
||||
return {
|
||||
albumName: entity.albumName,
|
||||
description: entity.description,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
owner: mapUser(entity.owner),
|
||||
albumUsers: albumUsersSorted,
|
||||
shared: hasSharedUser || hasSharedLink,
|
||||
hasSharedLink,
|
||||
startDate,
|
||||
endDate,
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
order: entity.order,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ValidateUUID } from 'src/validation';
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
export enum AssetIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
NO_PERMISSION = 'no_permission',
|
||||
NOT_FOUND = 'not_found',
|
||||
}
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
export class AssetIdsResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ApiProperty({ description: 'Whether operation succeeded' })
|
||||
success!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Error reason if failed', enum: AssetIdErrorReason })
|
||||
error?: AssetIdErrorReason;
|
||||
}
|
||||
|
||||
export enum BulkIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
NO_PERMISSION = 'no_permission',
|
||||
NOT_FOUND = 'not_found',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ValidateUUID({ each: true, description: 'IDs to process' })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkIdResponseDto {
|
||||
@ApiProperty({ description: 'ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Whether operation succeeded' })
|
||||
success!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
|
||||
error?: BulkIdErrorReason;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export enum AssetMediaStatus {
|
||||
CREATED = 'created',
|
||||
REPLACED = 'replaced',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
export class AssetMediaResponseDto {
|
||||
@ValidateEnum({ enum: AssetMediaStatus, name: 'AssetMediaStatus', description: 'Upload status' })
|
||||
status!: AssetMediaStatus;
|
||||
@ApiProperty({ description: 'Asset media ID' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export enum AssetUploadAction {
|
||||
ACCEPT = 'accept',
|
||||
REJECT = 'reject',
|
||||
}
|
||||
|
||||
export enum AssetRejectReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
UNSUPPORTED_FORMAT = 'unsupported-format',
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckResult {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Upload action', enum: AssetUploadAction })
|
||||
action!: AssetUploadAction;
|
||||
@ApiPropertyOptional({ description: 'Rejection reason if rejected', enum: AssetRejectReason })
|
||||
reason?: AssetRejectReason;
|
||||
@ApiPropertyOptional({ description: 'Existing asset ID if duplicate' })
|
||||
assetId?: string;
|
||||
@ApiPropertyOptional({ description: 'Whether existing asset is trashed' })
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckResponseDto {
|
||||
@ApiProperty({ description: 'Upload check results' })
|
||||
results!: AssetBulkUploadCheckResult[];
|
||||
}
|
||||
|
||||
export class CheckExistingAssetsResponseDto {
|
||||
@ApiProperty({ description: 'Existing asset IDs' })
|
||||
existingIds!: string[];
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
Original = 'original',
|
||||
/**
|
||||
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
|
||||
* or otherwise the original image itself.
|
||||
*/
|
||||
FULLSIZE = 'fullsize',
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
|
||||
size?: AssetMediaSize;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
||||
class AssetMediaBase {
|
||||
@ApiProperty({ description: 'Device asset ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
|
||||
@ValidateDate({ description: 'File creation date' })
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@ValidateDate({ description: 'File modification date' })
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Duration (for videos)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filename' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
filename?: string;
|
||||
|
||||
// The properties below are added to correctly generate the API docs
|
||||
// and client SDKs. Validation should be handled in the controller.
|
||||
@ApiProperty({ type: 'string', format: 'binary', description: 'Asset file data' })
|
||||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
}
|
||||
|
||||
export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility', optional: true })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Asset metadata items' })
|
||||
@Transform(({ value }) => {
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
const items = Array.isArray(json) ? json : [json];
|
||||
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
|
||||
} catch {
|
||||
throw new BadRequestException(['metadata must be valid JSON']);
|
||||
}
|
||||
})
|
||||
@Optional()
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
metadata?: AssetMetadataUpsertItemDto[];
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false, description: 'Sidecar file data' })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto extends AssetMediaBase {}
|
||||
|
||||
export class AssetBulkUploadCheckItem {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Base64 or hex encoded SHA1 hash' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
checksum!: string;
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckDto {
|
||||
@ApiProperty({ description: 'Assets to check' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetBulkUploadCheckItem)
|
||||
assets!: AssetBulkUploadCheckItem[];
|
||||
}
|
||||
|
||||
export class CheckExistingAssetsDto {
|
||||
@ApiProperty({ description: 'Device asset IDs to check' })
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
deviceAssetIds!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
deviceId!: string;
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
|
||||
describe('mapAsset', () => {
|
||||
describe('peopleWithFaces', () => {
|
||||
it('should transform all faces when a person has multiple faces in the same image', () => {
|
||||
const face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'assetFaceId-second',
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 400,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 500,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [face1, face2],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
expect(result.people![0].faces).toHaveLength(2);
|
||||
|
||||
// Verify that both faces have been transformed (bounding boxes adjusted for crop)
|
||||
const firstFace = result.people![0].faces[0];
|
||||
const secondFace = result.people![0].faces[1];
|
||||
|
||||
// After crop (x: 216, y: 1512), the coordinates should be adjusted
|
||||
// Faces outside the crop area will be clamped
|
||||
expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116
|
||||
expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412
|
||||
expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16
|
||||
expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312
|
||||
|
||||
expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216
|
||||
expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112
|
||||
expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216
|
||||
expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012
|
||||
});
|
||||
|
||||
it('should transform unassigned faces with edits and dimensions', () => {
|
||||
const unassignedFace = {
|
||||
...faceStub.noPerson1,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [unassignedFace],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 50, y: 50, width: 500, height: 400 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.unassignedFaces).toBeDefined();
|
||||
expect(result.unassignedFaces).toHaveLength(1);
|
||||
|
||||
// Verify that unassigned face has been transformed
|
||||
const face = result.unassignedFaces![0];
|
||||
expect(face.boundingBoxX1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxY1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxX2).toBe(150); // 200 - 50
|
||||
expect(face.boundingBoxY2).toBe(150); // 200 - 50
|
||||
});
|
||||
|
||||
it('should handle multiple people each with multiple faces', () => {
|
||||
const person1Face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-1',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person1Face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-2',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 400,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person2Face1 = {
|
||||
...faceStub.mergeFace1,
|
||||
id: 'face-2-1',
|
||||
person: personStub.mergePerson,
|
||||
personId: personStub.mergePerson.id,
|
||||
boundingBoxX1: 500,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 600,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [person1Face1, person1Face2, person2Face1],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [],
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(2);
|
||||
|
||||
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
|
||||
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
|
||||
|
||||
expect(person1).toBeDefined();
|
||||
expect(person1!.faces).toHaveLength(2);
|
||||
// No edits, so coordinates should be unchanged
|
||||
expect(person1!.faces[0].boundingBoxX1).toBe(100);
|
||||
expect(person1!.faces[0].boundingBoxY1).toBe(100);
|
||||
expect(person1!.faces[1].boundingBoxX1).toBe(300);
|
||||
expect(person1!.faces[1].boundingBoxY1).toBe(300);
|
||||
|
||||
expect(person2).toBeDefined();
|
||||
expect(person2!.faces).toHaveLength(1);
|
||||
expect(person2!.faces[0].boundingBoxX1).toBe(500);
|
||||
expect(person2!.faces[0].boundingBoxY1).toBe(100);
|
||||
});
|
||||
|
||||
it('should combine faces of the same person into a single entry', () => {
|
||||
const face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-2',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 400,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [face1, face2],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [],
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
|
||||
const person = result.people![0];
|
||||
expect(person.id).toBe(personStub.withName.id);
|
||||
expect(person.faces).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Selectable } from 'kysely';
|
||||
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||
import {
|
||||
AssetFaceWithoutPersonResponseDto,
|
||||
PersonWithFacesResponseDto,
|
||||
mapFacesWithoutPerson,
|
||||
mapPerson,
|
||||
} from 'src/dtos/person.dto';
|
||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { ImageDimensions } from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class SanitizedAssetResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
|
||||
type!: AssetType;
|
||||
@ApiProperty({ description: 'Thumbhash for thumbnail generation' })
|
||||
thumbhash!: string | null;
|
||||
@ApiPropertyOptional({ description: 'Original MIME type' })
|
||||
originalMimeType?: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
||||
example: '2024-01-15T14:30:00.000Z',
|
||||
})
|
||||
localDateTime!: Date;
|
||||
@ApiProperty({ description: 'Video duration (for videos)' })
|
||||
duration!: string;
|
||||
@ApiPropertyOptional({ description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string | null;
|
||||
@ApiProperty({ description: 'Whether asset has metadata' })
|
||||
hasMetadata!: boolean;
|
||||
@ApiProperty({ description: 'Asset width' })
|
||||
width!: number | null;
|
||||
@ApiProperty({ description: 'Asset height' })
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
||||
example: '2024-01-15T20:30:00.000Z',
|
||||
})
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Device asset ID' })
|
||||
deviceAssetId!: string;
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
deviceId!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
owner?: UserResponseDto;
|
||||
@ValidateUUID({
|
||||
nullable: true,
|
||||
description: 'Library ID',
|
||||
history: new HistoryBuilder().added('v1').deprecated('v1'),
|
||||
})
|
||||
libraryId?: string | null;
|
||||
@ApiProperty({ description: 'Original file path' })
|
||||
originalPath!: string;
|
||||
@ApiProperty({ description: 'Original file name' })
|
||||
originalFileName!: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
||||
example: '2024-01-15T19:30:00.000Z',
|
||||
})
|
||||
fileCreatedAt!: Date;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
||||
example: '2024-01-16T10:15:00.000Z',
|
||||
})
|
||||
fileModifiedAt!: Date;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
||||
example: '2024-01-16T12:45:30.000Z',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ApiProperty({ description: 'Is archived' })
|
||||
isArchived!: boolean;
|
||||
@ApiProperty({ description: 'Is trashed' })
|
||||
isTrashed!: boolean;
|
||||
@ApiProperty({ description: 'Is offline' })
|
||||
isOffline!: boolean;
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility' })
|
||||
visibility!: AssetVisibility;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
exifInfo?: ExifResponseDto;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
tags?: TagResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
people?: PersonWithFacesResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
||||
@ApiProperty({ description: 'Base64 encoded SHA1 hash' })
|
||||
checksum!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
stack?: AssetStackResponseDto | null;
|
||||
@ApiPropertyOptional({ description: 'Duplicate group ID' })
|
||||
duplicateId?: string | null;
|
||||
|
||||
@Property({ description: 'Is resized', history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
|
||||
resized?: boolean;
|
||||
@Property({ description: 'Is edited', history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0') })
|
||||
isEdited!: boolean;
|
||||
}
|
||||
|
||||
export type MapAsset = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date | null;
|
||||
id: string;
|
||||
updateId: string;
|
||||
status: AssetStatus;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: AssetEditActionItem[];
|
||||
encodedVideoPath: string | null;
|
||||
exifInfo?: Selectable<Exif> | null;
|
||||
faces?: AssetFace[];
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
files?: AssetFile[];
|
||||
isExternal: boolean;
|
||||
isFavorite: boolean;
|
||||
isOffline: boolean;
|
||||
visibility: AssetVisibility;
|
||||
libraryId: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
owner?: User | null;
|
||||
ownerId: string;
|
||||
stack?: Stack | null;
|
||||
stackId: string | null;
|
||||
tags?: Tag[];
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
type: AssetType;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export class AssetStackResponseDto {
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Primary asset ID' })
|
||||
primaryAssetId!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets in stack' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export type AssetMapOptions = {
|
||||
stripMetadata?: boolean;
|
||||
withStack?: boolean;
|
||||
auth?: AuthDto;
|
||||
};
|
||||
|
||||
const peopleWithFaces = (
|
||||
faces?: AssetFace[],
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): PersonWithFacesResponseDto[] => {
|
||||
if (!faces) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const peopleFaces: Map<string, PersonWithFacesResponseDto> = new Map();
|
||||
|
||||
for (const face of faces) {
|
||||
if (!face.person) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!peopleFaces.has(face.person.id)) {
|
||||
peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] });
|
||||
}
|
||||
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
|
||||
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
|
||||
}
|
||||
|
||||
return [...peopleFaces.values()];
|
||||
};
|
||||
|
||||
const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
if (!entity.stack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entity.stack.id,
|
||||
primaryAssetId: entity.stack.primaryAssetId,
|
||||
assetCount: entity.stack.assetCount ?? entity.stack.assets.length + 1,
|
||||
};
|
||||
};
|
||||
|
||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
if (stripMetadata) {
|
||||
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
localDateTime: entity.localDateTime,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
hasMetadata: false,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
};
|
||||
return sanitizedAssetResponse as AssetResponseDto;
|
||||
}
|
||||
|
||||
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||
deviceId: entity.deviceId,
|
||||
libraryId: entity.libraryId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
originalFileName: entity.originalFileName,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
fileCreatedAt: entity.fileCreatedAt,
|
||||
fileModifiedAt: entity.fileModifiedAt,
|
||||
localDateTime: entity.localDateTime,
|
||||
updatedAt: entity.updatedAt,
|
||||
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
||||
isArchived: entity.visibility === AssetVisibility.Archive,
|
||||
isTrashed: !!entity.deletedAt,
|
||||
visibility: entity.visibility,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map((tag) => mapTag(tag)),
|
||||
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
||||
unassignedFaces: entity.faces
|
||||
?.filter((face) => !face.person)
|
||||
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
|
||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
hasMetadata: true,
|
||||
duplicateId: entity.duplicateId,
|
||||
resized: true,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
isEdited: entity.isEdited,
|
||||
};
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsTimeZone,
|
||||
Max,
|
||||
Min,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||
o.latitude !== undefined || o.longitude !== undefined;
|
||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||
|
||||
export class UpdateAssetBase {
|
||||
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true, description: 'Asset visibility' })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ApiProperty({ description: 'Original date and time' })
|
||||
@Optional()
|
||||
@IsDateString()
|
||||
dateTimeOriginal?: string;
|
||||
|
||||
@ApiProperty({ description: 'Latitude coordinate' })
|
||||
@ValidateGPS()
|
||||
@IsLatitude()
|
||||
@IsNotEmpty()
|
||||
latitude?: number;
|
||||
|
||||
@ApiProperty({ description: 'Longitude coordinate' })
|
||||
@ValidateGPS()
|
||||
@IsLongitude()
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
|
||||
@ApiProperty({ description: 'Rating' })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
rating?: number;
|
||||
|
||||
@ApiProperty({ description: 'Asset description' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs to update' })
|
||||
ids!: string[];
|
||||
|
||||
@ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' })
|
||||
duplicateId?: string | null;
|
||||
|
||||
@ApiProperty({ description: 'Relative time offset in seconds' })
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@Optional()
|
||||
@IsInt()
|
||||
dateTimeRelative?: number;
|
||||
|
||||
@ApiProperty({ description: 'Time zone (IANA timezone)' })
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@IsTimeZone()
|
||||
@Optional()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto extends UpdateAssetBase {
|
||||
@ValidateUUID({ optional: true, nullable: true, description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string | null;
|
||||
}
|
||||
|
||||
export class RandomAssetsDto {
|
||||
@ApiProperty({ description: 'Number of random assets to return' })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Type(() => Number)
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export class AssetBulkDeleteDto extends BulkIdsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Force delete even if in use' })
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class AssetIdsDto {
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export enum AssetJobName {
|
||||
REFRESH_FACES = 'refresh-faces',
|
||||
REFRESH_METADATA = 'refresh-metadata',
|
||||
REGENERATE_THUMBNAIL = 'regenerate-thumbnail',
|
||||
TRANSCODE_VIDEO = 'transcode-video',
|
||||
}
|
||||
|
||||
export class AssetJobsDto extends AssetIdsDto {
|
||||
@ValidateEnum({ enum: AssetJobName, name: 'AssetJobName', description: 'Job name' })
|
||||
name!: AssetJobName;
|
||||
}
|
||||
|
||||
export class AssetStatsDto {
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Filter by visibility', optional: true })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by trash status' })
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export class AssetStatsResponseDto {
|
||||
@ApiProperty({ description: 'Number of images', type: 'integer' })
|
||||
images!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of videos', type: 'integer' })
|
||||
videos!: number;
|
||||
|
||||
@ApiProperty({ description: 'Total number of assets', type: 'integer' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class AssetMetadataRouteParams {
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertDto {
|
||||
@ApiProperty({ description: 'Metadata items to upsert' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataUpsertItemDto)
|
||||
items!: AssetMetadataUpsertItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertItemDto {
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkUpsertDto {
|
||||
@ApiProperty({ description: 'Metadata items to upsert' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkUpsertItemDto)
|
||||
items!: AssetMetadataBulkUpsertItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkUpsertItemDto {
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteDto {
|
||||
@ApiProperty({ description: 'Metadata items to delete' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkDeleteItemDto)
|
||||
items!: AssetMetadataBulkDeleteItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteItemDto {
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataResponseDto {
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
value!: object;
|
||||
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class AssetCopyDto {
|
||||
@ValidateUUID({ description: 'Source asset ID' })
|
||||
sourceId!: string;
|
||||
|
||||
@ValidateUUID({ description: 'Target asset ID' })
|
||||
targetId!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Copy shared links', default: true })
|
||||
sharedLinks?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Copy album associations', default: true })
|
||||
albums?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Copy sidecar file', default: true })
|
||||
sidecar?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Copy stack association', default: true })
|
||||
stack?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Copy favorite status', default: true })
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export class AssetDownloadOriginalDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
return {
|
||||
images: stats[AssetType.Image],
|
||||
videos: stats[AssetType.Video],
|
||||
total: Object.values(stats).reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie, UserMetadataKey } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { Optional, PinCode, toEmail, ValidateBoolean } from 'src/validation';
|
||||
import { AuthApiKey, AuthSession, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { toEmail, ValidateBoolean } from 'src/validation';
|
||||
|
||||
export type CookieResponse = {
|
||||
isSecure: boolean;
|
||||
@@ -17,8 +16,7 @@ export class AuthDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'API key (if authenticated via API key)' })
|
||||
apiKey?: AuthApiKey;
|
||||
@ApiPropertyOptional({ description: 'Shared link (if authenticated via shared link)' })
|
||||
sharedLink?: AuthSharedLink;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Session (if authenticated via session)' })
|
||||
session?: AuthSession;
|
||||
}
|
||||
@@ -45,30 +43,20 @@ export class LoginResponseDto {
|
||||
userEmail!: string;
|
||||
@ApiProperty({ description: 'User name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Profile image path' })
|
||||
profileImagePath!: string;
|
||||
@ApiProperty({ description: 'Is admin user' })
|
||||
isAdmin!: boolean;
|
||||
@ApiProperty({ description: 'Should change password' })
|
||||
shouldChangePassword!: boolean;
|
||||
@ApiProperty({ description: 'Is onboarded' })
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
|
||||
const onboardingMetadata = entity.metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.Onboarding> => item.key === UserMetadataKey.Onboarding,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
userId: entity.id,
|
||||
userEmail: entity.email,
|
||||
name: entity.name,
|
||||
isAdmin: entity.isAdmin,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isOnboarded: onboardingMetadata?.isOnboarded ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,85 +90,12 @@ export class ChangePasswordDto {
|
||||
invalidateSessions?: boolean;
|
||||
}
|
||||
|
||||
export class PinCodeSetupDto {
|
||||
@ApiProperty({ description: 'PIN code (4-6 digits)' })
|
||||
@PinCode()
|
||||
pinCode!: string;
|
||||
}
|
||||
|
||||
export class PinCodeResetDto {
|
||||
@ApiPropertyOptional({ description: 'New PIN code (4-6 digits)' })
|
||||
@PinCode({ optional: true })
|
||||
pinCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User password (required if PIN code is not provided)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class SessionUnlockDto extends PinCodeResetDto {}
|
||||
|
||||
export class PinCodeChangeDto extends PinCodeResetDto {
|
||||
@ApiProperty({ description: 'New PIN code (4-6 digits)' })
|
||||
@PinCode()
|
||||
newPinCode!: string;
|
||||
}
|
||||
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
@ApiProperty({ description: 'Authentication status' })
|
||||
authStatus!: boolean;
|
||||
}
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@ApiProperty({ description: 'OAuth callback URL' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state parameter' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth code verifier (PKCE)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeVerifier?: string;
|
||||
}
|
||||
|
||||
export class OAuthConfigDto {
|
||||
@ApiProperty({ description: 'OAuth redirect URI' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
redirectUri!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state parameter' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth code challenge (PKCE)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeChallenge?: string;
|
||||
}
|
||||
|
||||
export class OAuthAuthorizeResponseDto {
|
||||
@ApiProperty({ description: 'OAuth authorization URL' })
|
||||
url!: string;
|
||||
}
|
||||
|
||||
export class AuthStatusResponseDto {
|
||||
@ApiProperty({ description: 'Has PIN code set' })
|
||||
pinCode!: boolean;
|
||||
@ApiProperty({ description: 'Has password set' })
|
||||
password!: boolean;
|
||||
@ApiProperty({ description: 'Is elevated session' })
|
||||
isElevated!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Session expiration date' })
|
||||
expiresAt?: string;
|
||||
@ApiPropertyOptional({ description: 'PIN expiration date' })
|
||||
pinExpiresAt?: string;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
backups!: DatabaseBackupDto[];
|
||||
}
|
||||
|
||||
export class DatabaseBackupUploadDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
file?: any;
|
||||
}
|
||||
|
||||
export class DatabaseBackupDeleteDto {
|
||||
@IsString({ each: true })
|
||||
backups!: string[];
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsPositive } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DownloadInfoDto {
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' })
|
||||
assetIds?: string[];
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'Album ID to download' })
|
||||
albumId?: string;
|
||||
|
||||
@ValidateUUID({ optional: true, description: 'User ID to download assets from' })
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'integer', description: 'Archive size limit in bytes' })
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Optional()
|
||||
archiveSize?: number;
|
||||
}
|
||||
|
||||
export class DownloadResponseDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Total size in bytes' })
|
||||
totalSize!: number;
|
||||
@ApiProperty({ description: 'Archive information' })
|
||||
archives!: DownloadArchiveInfo[];
|
||||
}
|
||||
|
||||
export class DownloadArchiveInfo {
|
||||
@ApiProperty({ type: 'integer', description: 'Archive size in bytes' })
|
||||
size!: number;
|
||||
@ApiProperty({ description: 'Asset IDs in this archive' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
|
||||
export class DuplicateResponseDto {
|
||||
@ApiProperty({ description: 'Duplicate group ID' })
|
||||
duplicateId!: string;
|
||||
@ApiProperty({ description: 'Duplicate assets' })
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetEditAction {
|
||||
Crop = 'crop',
|
||||
Rotate = 'rotate',
|
||||
Mirror = 'mirror',
|
||||
}
|
||||
|
||||
export enum MirrorAxis {
|
||||
Horizontal = 'horizontal',
|
||||
Vertical = 'vertical',
|
||||
}
|
||||
|
||||
export class CropParameters {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ description: 'Top-Left X coordinate of crop' })
|
||||
x!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ description: 'Top-Left Y coordinate of crop' })
|
||||
y!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@ApiProperty({ description: 'Width of the crop' })
|
||||
width!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@ApiProperty({ description: 'Height of the crop' })
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class RotateParameters {
|
||||
@IsAxisAlignedRotation()
|
||||
@ApiProperty({ description: 'Rotation angle in degrees' })
|
||||
angle!: number;
|
||||
}
|
||||
|
||||
export class MirrorParameters {
|
||||
@IsEnum(MirrorAxis)
|
||||
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
||||
axis!: MirrorAxis;
|
||||
}
|
||||
|
||||
class AssetEditActionBase {
|
||||
@IsEnum(AssetEditAction)
|
||||
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
}
|
||||
|
||||
export class AssetEditActionCrop extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => CropParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: CropParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionRotate extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => RotateParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: RotateParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionMirror extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => MirrorParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: MirrorParameters;
|
||||
}
|
||||
|
||||
export type AssetEditActionItem =
|
||||
| {
|
||||
action: AssetEditAction.Crop;
|
||||
parameters: CropParameters;
|
||||
}
|
||||
| {
|
||||
action: AssetEditAction.Rotate;
|
||||
parameters: RotateParameters;
|
||||
}
|
||||
| {
|
||||
action: AssetEditAction.Mirror;
|
||||
parameters: MirrorParameters;
|
||||
};
|
||||
|
||||
export type AssetEditActionParameter = {
|
||||
[AssetEditAction.Crop]: CropParameters;
|
||||
[AssetEditAction.Rotate]: RotateParameters;
|
||||
[AssetEditAction.Mirror]: MirrorParameters;
|
||||
};
|
||||
|
||||
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
|
||||
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
|
||||
[AssetEditAction.Crop]: AssetEditActionCrop,
|
||||
[AssetEditAction.Rotate]: AssetEditActionRotate,
|
||||
[AssetEditAction.Mirror]: AssetEditActionMirror,
|
||||
} as const;
|
||||
|
||||
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
|
||||
actionToClass[item.action];
|
||||
|
||||
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
|
||||
export class AssetEditActionListDto {
|
||||
/** list of edits */
|
||||
@ArrayMinSize(1)
|
||||
@IsUniqueEditActions()
|
||||
@ValidateNested({ each: true })
|
||||
@Transform(({ value: edits }) =>
|
||||
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
|
||||
)
|
||||
@ApiProperty({
|
||||
anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })),
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
})
|
||||
edits!: AssetEditActionItem[];
|
||||
}
|
||||
|
||||
export class AssetEditsDto extends AssetEditActionListDto {
|
||||
@ValidateUUID({ description: 'Asset ID to apply edits to' })
|
||||
assetId!: string;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class EnvDto {
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
IMMICH_API_METRICS_PORT?: number;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_BUILD_DATA?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_BUILD?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_BUILD_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_BUILD_IMAGE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_BUILD_IMAGE_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_CONFIG_FILE?: string;
|
||||
|
||||
@IsEnum(ImmichEnvironment)
|
||||
@Optional()
|
||||
IMMICH_ENV?: ImmichEnvironment;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_HOST?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_IGNORE_MOUNT_CHECK_ERRORS?: boolean;
|
||||
|
||||
@IsEnum(LogLevel)
|
||||
@Optional()
|
||||
IMMICH_LOG_LEVEL?: LogLevel;
|
||||
|
||||
@IsEnum(LogFormat)
|
||||
@Optional()
|
||||
IMMICH_LOG_FORMAT?: LogFormat;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' })
|
||||
IMMICH_MEDIA_LOCATION?: string;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
IMMICH_MICROSERVICES_METRICS_PORT?: number;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
|
||||
IMMICH_PLUGINS_INSTALL_FOLDER?: string;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
IMMICH_PORT?: number;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_REPOSITORY?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_REPOSITORY_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_SOURCE_REF?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_SOURCE_COMMIT?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_SOURCE_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_TELEMETRY_INCLUDE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_TELEMETRY_EXCLUDE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_THIRD_PARTY_SOURCE_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_ALLOW_SETUP?: boolean;
|
||||
|
||||
@IsIPRange({ requireCIDR: false }, { each: true })
|
||||
@Transform(({ value }) =>
|
||||
value && typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: value,
|
||||
)
|
||||
@Optional()
|
||||
IMMICH_TRUSTED_PROXIES?: string[];
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_WORKERS_INCLUDE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_WORKERS_EXCLUDE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_DATABASE_NAME?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_HOSTNAME?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_PASSWORD?: string;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
DB_PORT?: number;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
DB_SKIP_MIGRATIONS?: boolean;
|
||||
|
||||
@IsEnum(DatabaseSslMode)
|
||||
@Optional()
|
||||
DB_SSL_MODE?: DatabaseSslMode;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_URL?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_USERNAME?: string;
|
||||
|
||||
@IsEnum(['pgvector', 'pgvecto.rs', 'vectorchord'])
|
||||
@Optional()
|
||||
DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs' | 'vectorchord';
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
NO_COLOR?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_HOSTNAME?: string;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
REDIS_PORT?: number;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
REDIS_DBINDEX?: number;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_USERNAME?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_PASSWORD?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_SOCKET?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
REDIS_URL?: string;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Exif } from 'src/database';
|
||||
|
||||
export class ExifResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Camera make' })
|
||||
make?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Camera model' })
|
||||
model?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Image width in pixels' })
|
||||
exifImageWidth?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Image height in pixels' })
|
||||
exifImageHeight?: number | null = null;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'File size in bytes' })
|
||||
fileSizeInByte?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'Image orientation' })
|
||||
orientation?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
|
||||
dateTimeOriginal?: Date | null = null;
|
||||
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
|
||||
modifyDate?: Date | null = null;
|
||||
@ApiPropertyOptional({ description: 'Time zone' })
|
||||
timeZone?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Lens model' })
|
||||
lensModel?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'F-number (aperture)' })
|
||||
fNumber?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Focal length in mm' })
|
||||
focalLength?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'ISO sensitivity' })
|
||||
iso?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'Exposure time' })
|
||||
exposureTime?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'GPS latitude' })
|
||||
latitude?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'GPS longitude' })
|
||||
longitude?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'City name' })
|
||||
city?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'State/province name' })
|
||||
state?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Country name' })
|
||||
country?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Image description' })
|
||||
description?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Projection type' })
|
||||
projectionType?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Rating' })
|
||||
rating?: number | null = null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: Exif): ExifResponseDto {
|
||||
return {
|
||||
make: entity.make,
|
||||
model: entity.model,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
modifyDate: entity.modifyDate,
|
||||
timeZone: entity.timeZone,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
focalLength: entity.focalLength,
|
||||
iso: entity.iso,
|
||||
exposureTime: entity.exposureTime,
|
||||
latitude: entity.latitude,
|
||||
longitude: entity.longitude,
|
||||
city: entity.city,
|
||||
state: entity.state,
|
||||
country: entity.country,
|
||||
description: entity.description,
|
||||
projectionType: entity.projectionType,
|
||||
rating: entity.rating,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSanitizedExif(entity: Exif): ExifResponseDto {
|
||||
return {
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
timeZone: entity.timeZone,
|
||||
projectionType: entity.projectionType,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
rating: entity.rating,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ManualJobName } from 'src/enum';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class JobCreateDto {
|
||||
@ValidateEnum({ enum: ManualJobName, name: 'ManualJobName', description: 'Job name' })
|
||||
name!: ManualJobName;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Library } from 'src/database';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class CreateLibraryDto {
|
||||
@ValidateUUID({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Library name' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export class UpdateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Library name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface CrawlOptionsDto {
|
||||
pathsToCrawl: string[];
|
||||
includeHidden?: boolean;
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface WalkOptionsDto extends CrawlOptionsDto {
|
||||
take: number;
|
||||
}
|
||||
|
||||
export class ValidateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Validation results for import paths' })
|
||||
importPaths?: ValidateLibraryImportPathResponseDto[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryImportPathResponseDto {
|
||||
@ApiProperty({ description: 'Import path' })
|
||||
importPath!: string;
|
||||
@ApiProperty({ description: 'Is valid' })
|
||||
isValid: boolean = false;
|
||||
@ApiPropertyOptional({ description: 'Validation message' })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class LibrarySearchDto {
|
||||
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class LibraryResponseDto {
|
||||
@ApiProperty({ description: 'Library ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Library name' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||
assetCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Import paths' })
|
||||
importPaths!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Exclusion patterns' })
|
||||
exclusionPatterns!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Last refresh date' })
|
||||
refreshedAt!: Date | null;
|
||||
}
|
||||
|
||||
export class LibraryStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Number of photos' })
|
||||
photos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Number of videos' })
|
||||
videos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
|
||||
total = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
||||
usage = 0;
|
||||
}
|
||||
|
||||
export function mapLibrary(entity: Library): LibraryResponseDto {
|
||||
let assetCount = 0;
|
||||
if (entity.assets) {
|
||||
assetCount = entity.assets.length;
|
||||
}
|
||||
return {
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
name: entity.name,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
refreshedAt: entity.refreshedAt,
|
||||
assetCount,
|
||||
importPaths: entity.importPaths,
|
||||
exclusionPatterns: entity.exclusionPatterns,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user