1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2024-12-23 01:27:14 +02:00

Merge pull request #768 from bpatrik/feature/extension

Add backend extension support #743
This commit is contained in:
Patrik J. Braun 2023-11-19 23:12:54 +01:00 committed by GitHub
commit d38830f8be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 1700 additions and 538 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ locale.source.xlf
test.*
/db/
/test/cypress/screenshots/
/extensions/

View File

@ -121,7 +121,7 @@ export class BenchmarkRunner {
const bm = new Benchmark('List directory', req,
async (): Promise<void> => {
await ObjectManagers.reset();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
}, null,
async (): Promise<void> => {
Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low;
@ -135,7 +135,7 @@ export class BenchmarkRunner {
async bmListPersons(): Promise<BenchmarkResult[]> {
const bm = new Benchmark('Listing Faces', Utils.clone(this.requestTemplate), async (): Promise<void> => {
await ObjectManagers.reset();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
}, null,
async (): Promise<void> => {
Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low;
@ -289,7 +289,7 @@ export class BenchmarkRunner {
await fs.promises.rm(ProjectPath.DBFolder, {recursive: true, force: true});
Config.Database.type = DatabaseType.sqlite;
Config.Jobs.scheduled = [];
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
};
private async setupDB(): Promise<void> {

21
package-lock.json generated
View File

@ -90,7 +90,7 @@
"codelyzer": "6.0.2",
"core-js": "3.29.0",
"coveralls": "3.1.1",
"cypress": "latest",
"cypress": "13.1.0",
"deep-equal-in-any-order": "2.0.5",
"ejs-loader": "0.5.0",
"eslint": "8.36.0",
@ -132,6 +132,13 @@
"mysql": "2.18.1"
}
},
"extensions/logger": {
"version": "1.0.0",
"extraneous": true,
"dependencies": {
"lodash": "4.17.21"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -8646,9 +8653,9 @@
}
},
"node_modules/cypress/node_modules/@types/node": {
"version": "16.18.48",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz",
"integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==",
"version": "16.18.61",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.61.tgz",
"integrity": "sha512-k0N7BqGhJoJzdh6MuQg1V1ragJiXTh8VUBAZTWjJ9cUq23SG0F0xavOwZbhiP4J3y20xd6jxKx+xNUhkMAi76Q==",
"dev": true
},
"node_modules/cypress/node_modules/ansi-styles": {
@ -30554,9 +30561,9 @@
},
"dependencies": {
"@types/node": {
"version": "16.18.48",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz",
"integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==",
"version": "16.18.61",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.61.tgz",
"integrity": "sha512-k0N7BqGhJoJzdh6MuQg1V1ragJiXTh8VUBAZTWjJ9cUq23SG0F0xavOwZbhiP4J3y20xd6jxKx+xNUhkMAi76Q==",
"dev": true
},
"ansi-styles": {

View File

@ -13,8 +13,8 @@
"create-release": "gulp create-release",
"build-backend": "tsc",
"pretest": "tsc",
"test": "ng test && nyc mocha --recursive test",
"test-backend": "tsc && mocha --recursive test",
"test": "ng test && nyc mocha --recursive test --exclude test/cypress/**/*.js",
"test-backend": "tsc && mocha --recursive test --exclude test/cypress/**/*.js",
"coverage": "nyc report --reporter=lcov",
"start": "node ./src/backend/index",
"run-dev": "ng build --configuration=dev",
@ -113,7 +113,7 @@
"codelyzer": "6.0.2",
"core-js": "3.29.0",
"coveralls": "3.1.1",
"cypress": "latest",
"cypress": "13.1.0",
"deep-equal-in-any-order": "2.0.5",
"ejs-loader": "0.5.0",
"eslint": "8.36.0",

View File

@ -7,10 +7,42 @@ const forcedDebug = process.env['NODE_ENV'] === 'debug';
if (forcedDebug === true) {
console.log(
'NODE_ENV environmental variable is set to debug, forcing all logs to print'
'NODE_ENV environmental variable is set to debug, forcing all logs to print'
);
}
export type LoggerFunction = (...args: (string | number)[]) => void;
export interface ILogger {
silly: LoggerFunction;
debug: LoggerFunction;
verbose: LoggerFunction;
info: LoggerFunction;
warn: LoggerFunction;
error: LoggerFunction;
}
export const createLoggerWrapper = (TAG: string): ILogger => ({
silly: (...args: (string | number)[]) => {
Logger.silly(TAG, ...args);
},
debug: (...args: (string | number)[]) => {
Logger.debug(TAG, ...args);
},
verbose: (...args: (string | number)[]) => {
Logger.verbose(TAG, ...args);
},
info: (...args: (string | number)[]) => {
Logger.info(TAG, ...args);
},
warn: (...args: (string | number)[]) => {
Logger.warn(TAG, ...args);
},
error: (...args: (string | number)[]) => {
Logger.error(TAG, ...args);
}
});
export class Logger {
public static silly(...args: (string | number)[]): void {
if (!forcedDebug && Config.Server.Log.level < LogLevel.silly) {
@ -55,10 +87,10 @@ export class Logger {
const date = new Date().toLocaleString();
let LOG_TAG = '';
if (
args.length > 0 &&
typeof args[0] === 'string' &&
args[0].startsWith('[') &&
args[0].endsWith(']')
args.length > 0 &&
typeof args[0] === 'string' &&
args[0].startsWith('[') &&
args[0].endsWith(']')
) {
LOG_TAG = args[0];
args.shift();

View File

@ -2,28 +2,29 @@ import * as path from 'path';
import * as fs from 'fs';
import {Config} from '../common/config/private/Config';
class ProjectPathClass {
export class ProjectPathClass {
public Root: string;
public ImageFolder: string;
public TempFolder: string;
public TranscodedFolder: string;
public FacesFolder: string;
public FrontendFolder: string;
public ExtensionFolder: string;
public DBFolder: string;
constructor() {
this.reset();
}
normalizeRelative(pathStr: string): string {
public normalizeRelative(pathStr: string): string {
return path.join(pathStr, path.sep);
}
getAbsolutePath(pathStr: string): string {
public getAbsolutePath(pathStr: string): string {
return path.isAbsolute(pathStr) ? pathStr : path.join(this.Root, pathStr);
}
getRelativePathToImages(pathStr: string): string {
public getRelativePathToImages(pathStr: string): string {
return path.relative(this.ImageFolder, pathStr);
}
@ -35,6 +36,7 @@ class ProjectPathClass {
this.TranscodedFolder = path.join(this.TempFolder, 'tc');
this.FacesFolder = path.join(this.TempFolder, 'f');
this.DBFolder = this.getAbsolutePath(Config.Database.dbFolder);
this.ExtensionFolder = path.join(this.Root, 'extensions');
// create thumbnail folder if not exist
if (!fs.existsSync(this.TempFolder)) {

View File

@ -11,5 +11,5 @@ if ((process.argv || []).includes('--run-diagnostics')) {
process.exit(0);
});
} else {
new Server();
Server.getInstance();
}

View File

@ -9,6 +9,7 @@ import {SharingDTO} from '../../common/entities/SharingDTO';
import {Utils} from '../../common/Utils';
import {LoggerRouter} from '../routes/LoggerRouter';
import {TAGS} from '../../common/config/public/ClientConfig';
import {ExtensionConfigWrapper} from '../model/extension/ExtensionConfigWrapper';
const forcedDebug = process.env['NODE_ENV'] === 'debug';
@ -107,7 +108,7 @@ export class RenderingMWs {
req: Request,
res: Response
): Promise<void> {
const originalConf = await Config.original();
const originalConf = await ExtensionConfigWrapper.original();
// These are sensitive information, do not send to the client side
originalConf.Server.sessionSecret = null;
const message = new Message<PrivateConfigClass>(

View File

@ -2,18 +2,19 @@ import {NextFunction, Request, Response} from 'express';
import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error';
import {ObjectManagers} from '../../model/ObjectManagers';
import {StatisticDTO} from '../../../common/entities/settings/StatisticDTO';
import {MessengerRepository} from '../../model/messenger/MessengerRepository';
export class AdminMWs {
public static async loadStatistic(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const galleryManager = ObjectManagers.getInstance()
.GalleryManager;
.GalleryManager;
const personManager = ObjectManagers.getInstance()
.PersonManager;
.PersonManager;
try {
req.resultPipe = {
directories: await galleryManager.countDirectories(),
@ -26,87 +27,87 @@ export class AdminMWs {
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting statistic: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting statistic: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting statistic',
err
)
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting statistic',
err
)
);
}
}
public static async getDuplicates(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
req.resultPipe = await ObjectManagers.getInstance()
.GalleryManager.getPossibleDuplicates();
.GalleryManager.getPossibleDuplicates();
return next();
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting duplicates: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting duplicates: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting duplicates',
err
)
new ErrorDTO(
ErrorCodes.GENERAL_ERROR,
'Error while getting duplicates',
err
)
);
}
}
public static async startJob(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const id = req.params['id'];
const JobConfig: unknown = req.body.config;
const JobConfig: Record<string, unknown> = req.body.config;
const soloRun: boolean = req.body.soloRun;
const allowParallelRun: boolean = req.body.allowParallelRun;
await ObjectManagers.getInstance().JobManager.run(
id,
JobConfig,
soloRun,
allowParallelRun
id,
JobConfig,
soloRun,
allowParallelRun
);
req.resultPipe = 'ok';
return next();
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}
@ -120,56 +121,85 @@ export class AdminMWs {
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}
public static getAvailableMessengers(
req: Request,
res: Response,
next: NextFunction
): void {
try {
req.resultPipe = MessengerRepository.Instance.getAll().map(msgr => msgr.Name);
return next();
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
ErrorCodes.JOB_ERROR,
'Messenger error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Messenger error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}
public static getAvailableJobs(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): void {
try {
req.resultPipe =
ObjectManagers.getInstance().JobManager.getAvailableJobs();
ObjectManagers.getInstance().JobManager.getAvailableJobs();
return next();
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}
public static getJobProgresses(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): void {
try {
req.resultPipe = ObjectManagers.getInstance().JobManager.getProgresses();
@ -177,19 +207,19 @@ export class AdminMWs {
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Job error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}

View File

@ -3,9 +3,10 @@ import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error';
import {Logger} from '../../Logger';
import {Config} from '../../../common/config/private/Config';
import {ConfigDiagnostics} from '../../model/diagnostics/ConfigDiagnostics';
import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node';
import {ConfigClassBuilder} from 'typeconfig/node';
import {TAGS} from '../../../common/config/public/ClientConfig';
import {ObjectManagers} from '../../model/ObjectManagers';
import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper';
const LOG_TAG = '[SettingsMWs]';
@ -28,7 +29,7 @@ export class SettingsMWs {
try {
let settings = req.body.settings; // Top level settings JSON
const settingsPath: string = req.body.settingsPath; // Name of the top level settings
const transformer = await Config.original();
const transformer = await ExtensionConfigWrapper.original();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
transformer[settingsPath] = settings;
@ -37,7 +38,7 @@ export class SettingsMWs {
settings = ConfigClassBuilder.attachPrivateInterface(transformer[settingsPath]).toJSON({
skipTags: {secret: true} as TAGS
});
const original = await Config.original();
const original = await ExtensionConfigWrapper.original();
// only updating explicitly set config (not saving config set by the diagnostics)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore

View File

@ -14,6 +14,7 @@ import {AlbumManager} from './database/AlbumManager';
import {PersonManager} from './database/PersonManager';
import {SharingManager} from './database/SharingManager';
import {IObjectManager} from './database/IObjectManager';
import {ExtensionManager} from './extension/ExtensionManager';
const LOG_TAG = '[ObjectManagers]';
@ -32,11 +33,99 @@ export class ObjectManagers {
private jobManager: JobManager;
private locationManager: LocationManager;
private albumManager: AlbumManager;
private extensionManager: ExtensionManager;
private initDone = false;
constructor() {
this.managers = [];
}
public static getInstance(): ObjectManagers {
if (!this.instance) {
this.instance = new ObjectManagers();
}
return this.instance;
}
public static async reset(): Promise<void> {
Logger.silly(LOG_TAG, 'Object manager reset begin');
if (ObjectManagers.isReady()) {
if (
ObjectManagers.getInstance().IndexingManager &&
ObjectManagers.getInstance().IndexingManager.IsSavingInProgress
) {
await ObjectManagers.getInstance().IndexingManager.SavingReady;
}
for (const manager of ObjectManagers.getInstance().managers) {
if (manager === ObjectManagers.getInstance().versionManager) {
continue;
}
if (manager.cleanUp) {
await manager.cleanUp();
}
}
}
await SQLConnection.close();
this.instance = null;
Logger.debug(LOG_TAG, 'Object manager reset done');
}
public static isReady(): boolean {
return this.instance && this.instance.initDone;
}
public async init(): Promise<void> {
if (this.initDone) {
return;
}
await SQLConnection.init();
await this.initManagers();
Logger.debug(LOG_TAG, 'SQL DB inited');
this.initDone = true;
}
private async initManagers(): Promise<void> {
this.AlbumManager = new AlbumManager();
this.GalleryManager = new GalleryManager();
this.IndexingManager = new IndexingManager();
this.PersonManager = new PersonManager();
this.CoverManager = new CoverManager();
this.SearchManager = new SearchManager();
this.SharingManager = new SharingManager();
this.UserManager = new UserManager();
this.VersionManager = new VersionManager();
this.JobManager = new JobManager();
this.LocationManager = new LocationManager();
this.ExtensionManager = new ExtensionManager();
for (const manager of ObjectManagers.getInstance().managers) {
if (manager === ObjectManagers.getInstance().versionManager) {
continue;
}
if (manager.init) {
await manager.init();
}
}
}
public async onDataChange(
changedDir: ParentDirectoryDTO = null
): Promise<void> {
await this.VersionManager.onNewDataVersion();
for (const manager of this.managers) {
if (manager === this.versionManager) {
continue;
}
if (manager.onNewDataVersion) {
await manager.onNewDataVersion(changedDir);
}
}
}
get VersionManager(): VersionManager {
return this.versionManager;
}
@ -169,62 +258,15 @@ export class ObjectManagers {
this.managers.push(this.jobManager as IObjectManager);
}
public static getInstance(): ObjectManagers {
if (this.instance === null) {
this.instance = new ObjectManagers();
}
return this.instance;
get ExtensionManager(): ExtensionManager {
return this.extensionManager;
}
public static async reset(): Promise<void> {
Logger.silly(LOG_TAG, 'Object manager reset begin');
if (
ObjectManagers.getInstance().IndexingManager &&
ObjectManagers.getInstance().IndexingManager.IsSavingInProgress
) {
await ObjectManagers.getInstance().IndexingManager.SavingReady;
}
if (ObjectManagers.getInstance().JobManager) {
ObjectManagers.getInstance().JobManager.stopSchedules();
}
await SQLConnection.close();
this.instance = null;
Logger.debug(LOG_TAG, 'Object manager reset');
}
public static async InitSQLManagers(): Promise<void> {
await ObjectManagers.reset();
await SQLConnection.init();
this.initManagers();
Logger.debug(LOG_TAG, 'SQL DB inited');
}
private static initManagers(): void {
ObjectManagers.getInstance().AlbumManager = new AlbumManager();
ObjectManagers.getInstance().GalleryManager = new GalleryManager();
ObjectManagers.getInstance().IndexingManager = new IndexingManager();
ObjectManagers.getInstance().PersonManager = new PersonManager();
ObjectManagers.getInstance().CoverManager = new CoverManager();
ObjectManagers.getInstance().SearchManager = new SearchManager();
ObjectManagers.getInstance().SharingManager = new SharingManager();
ObjectManagers.getInstance().UserManager = new UserManager();
ObjectManagers.getInstance().VersionManager = new VersionManager();
ObjectManagers.getInstance().JobManager = new JobManager();
ObjectManagers.getInstance().LocationManager = new LocationManager();
}
public async onDataChange(
changedDir: ParentDirectoryDTO = null
): Promise<void> {
await this.VersionManager.onNewDataVersion();
for (const manager of this.managers) {
if (manager === this.versionManager) {
continue;
}
if (manager.onNewDataVersion) {
await manager.onNewDataVersion(changedDir);
}
set ExtensionManager(value: ExtensionManager) {
if (this.extensionManager) {
this.managers.splice(this.managers.indexOf(this.extensionManager as IObjectManager), 1);
}
this.extensionManager = value;
this.managers.push(this.extensionManager as IObjectManager);
}
}

View File

@ -19,7 +19,7 @@ export class AlbumManager implements IObjectManager {
private static async updateAlbum(album: SavedSearchEntity): Promise<void> {
const connection = await SQLConnection.getConnection();
const cover =
await ObjectManagers.getInstance().CoverManager.getAlbumCover(album);
await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(album);
const count = await
ObjectManagers.getInstance().SearchManager.getCount((album as SavedSearchDTO).searchQuery);

View File

@ -14,6 +14,7 @@ import {CoverPhotoDTO} from '../../../common/entities/PhotoDTO';
import {IObjectManager} from './IObjectManager';
import {Logger} from '../../Logger';
import {SearchManager} from './SearchManager';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
const LOG_TAG = '[CoverManager]';
@ -29,21 +30,22 @@ export class CoverManager implements IObjectManager {
public async resetCovers(): Promise<void> {
const connection = await SQLConnection.getConnection();
await connection
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false})
.execute();
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false})
.execute();
}
public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise<void> {
@ExtensionDecorator(e => e.gallery.CoverManager.invalidateDirectoryCovers)
protected async invalidateDirectoryCovers(dir: ParentDirectoryDTO) {
// Invalidating Album cover
let fullPath = DiskManager.normalizeDirPath(
path.join(changedDir.path, changedDir.name)
path.join(dir.path, dir.name)
);
const query = (await SQLConnection.getConnection())
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false});
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false});
let i = 0;
const root = DiskManager.pathFromRelativeDirName('.');
@ -53,62 +55,67 @@ export class CoverManager implements IObjectManager {
fullPath = parentPath;
++i;
query.orWhere(
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = name;
param['path' + i] = parentPath;
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = name;
param['path' + i] = parentPath;
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
);
}
++i;
query.orWhere(
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = DiskManager.dirName('.');
param['path' + i] = DiskManager.pathFromRelativeDirName('.');
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = DiskManager.dirName('.');
param['path' + i] = DiskManager.pathFromRelativeDirName('.');
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
);
await query.execute();
}
public async getAlbumCover(album: {
public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise<void> {
await this.invalidateDirectoryCovers(changedDir);
}
@ExtensionDecorator(e => e.gallery.CoverManager.getCoverForAlbum)
public async getCoverForAlbum(album: {
searchQuery: SearchQueryDTO;
}): Promise<CoverPhotoDTOWithID> {
const albumQuery: Brackets = await
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery);
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery);
const connection = await SQLConnection.getConnection();
const coverQuery = (): SelectQueryBuilder<MediaEntity> => {
const query = connection
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(albumQuery);
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(albumQuery);
SearchManager.setSorting(query, Config.AlbumCover.Sorting);
return query;
};
let coverMedia = null;
if (
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
) {
try {
const coverFilterQuery = await
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery);
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery);
coverMedia = await coverQuery()
.andWhere(coverFilterQuery)
.limit(1)
.getOne();
.andWhere(coverFilterQuery)
.limit(1)
.getOne();
} catch (e) {
Logger.error(LOG_TAG, 'Cant get album cover using:', JSON.stringify(album.searchQuery), JSON.stringify(Config.AlbumCover.SearchQuery));
throw e;
@ -127,52 +134,53 @@ export class CoverManager implements IObjectManager {
}
public async getPartialDirsWithoutCovers(): Promise<
{ id: number; name: string; path: string }[]
{ id: number; name: string; path: string }[]
> {
const connection = await SQLConnection.getConnection();
return await connection
.getRepository(DirectoryEntity)
.createQueryBuilder('directory')
.where('directory.validCover = :validCover', {validCover: 0}) // 0 === false
.select(['name', 'id', 'path'])
.getRawMany();
.getRepository(DirectoryEntity)
.createQueryBuilder('directory')
.where('directory.validCover = :validCover', {validCover: 0}) // 0 === false
.select(['name', 'id', 'path'])
.getRawMany();
}
public async setAndGetCoverForDirectory(dir: {
@ExtensionDecorator(e => e.gallery.CoverManager.getCoverForDirectory)
protected async getCoverForDirectory(dir: {
id: number;
name: string;
path: string;
}): Promise<CoverPhotoDTOWithID> {
}) {
const connection = await SQLConnection.getConnection();
const coverQuery = (): SelectQueryBuilder<MediaEntity> => {
const query = connection
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(
new Brackets((q: WhereExpression) => {
q.where('media.directory = :dir', {
dir: dir.id,
});
if (Config.Database.type === DatabaseType.mysql) {
q.orWhere('directory.path like :path || \'%\'', {
path: DiskManager.pathFromParent(dir),
});
} else {
q.orWhere('directory.path GLOB :path', {
path: DiskManager.pathFromParent(dir)
// glob escaping. see https://github.com/bpatrik/pigallery2/issues/621
.replaceAll('[', '[[]') + '*',
});
}
})
);
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(
new Brackets((q: WhereExpression) => {
q.where('media.directory = :dir', {
dir: dir.id,
});
if (Config.Database.type === DatabaseType.mysql) {
q.orWhere('directory.path like :path || \'%\'', {
path: DiskManager.pathFromParent(dir),
});
} else {
q.orWhere('directory.path GLOB :path', {
path: DiskManager.pathFromParent(dir)
// glob escaping. see https://github.com/bpatrik/pigallery2/issues/621
.replaceAll('[', '[[]') + '*',
});
}
})
);
// Select from the directory if any otherwise from any subdirectories.
// (There is no priority between subdirectories)
query.orderBy(
`CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`,
'ASC'
`CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`,
'ASC'
);
SearchManager.setSorting(query, Config.AlbumCover.Sorting);
@ -181,33 +189,43 @@ export class CoverManager implements IObjectManager {
let coverMedia: CoverPhotoDTOWithID = null;
if (
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
) {
coverMedia = await coverQuery()
.andWhere(
await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery)
)
.limit(1)
.getOne();
.andWhere(
await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery)
)
.limit(1)
.getOne();
}
if (!coverMedia) {
coverMedia = await coverQuery().limit(1).getOne();
}
return coverMedia;
}
public async setAndGetCoverForDirectory(dir: {
id: number;
name: string;
path: string;
}): Promise<CoverPhotoDTOWithID> {
const connection = await SQLConnection.getConnection();
const coverMedia = await this.getCoverForDirectory(dir);
// set validCover bit to true even if there is no cover (to prevent future updates)
await connection
.createQueryBuilder()
.update(DirectoryEntity)
.set({cover: coverMedia, validCover: true})
.where('id = :dir', {
dir: dir.id,
})
.execute();
.createQueryBuilder()
.update(DirectoryEntity)
.set({cover: coverMedia, validCover: true})
.where('id = :dir', {
dir: dir.id,
})
.execute();
return coverMedia || null;
}

View File

@ -2,4 +2,6 @@ import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO';
export interface IObjectManager {
onNewDataVersion?: (changedDir?: ParentDirectoryDTO) => Promise<void>;
cleanUp?: () => Promise<void>;
init?: () => Promise<void>;
}

View File

@ -29,6 +29,38 @@ const LOG_TAG = '[SQLConnection]';
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export class SQLConnection {
// eslint-disable-next-line @typescript-eslint/ban-types
public static getEntries(): Function[] {
return this.entries;
}
// eslint-disable-next-line @typescript-eslint/ban-types
public static async addEntries(tables: Function[]) {
if (!tables?.length) {
return;
}
await this.close();
this.entries = Utils.getUnique(this.entries.concat(tables));
await (await this.getConnection()).synchronize();
}
// eslint-disable-next-line @typescript-eslint/ban-types
private static entries: Function[] = [
UserEntity,
FileEntity,
MDFileEntity,
PersonJunctionTable,
PersonEntry,
MediaEntity,
PhotoEntity,
VideoEntity,
DirectoryEntity,
SharingEntity,
AlbumBaseEntity,
SavedSearchEntity,
VersionEntity,
];
private static connection: Connection = null;
@ -37,10 +69,10 @@ export class SQLConnection {
const options = this.getDriver(Config.Database);
Logger.debug(
LOG_TAG,
'Creating connection: ' + DatabaseType[Config.Database.type],
', with driver:',
options.type
LOG_TAG,
'Creating connection: ' + DatabaseType[Config.Database.type],
', with driver:',
options.type
);
this.connection = await this.createConnection(options);
await SQLConnection.schemeSync(this.connection);
@ -49,7 +81,7 @@ export class SQLConnection {
}
public static async tryConnection(
config: ServerDataBaseConfig
config: ServerDataBaseConfig
): Promise<boolean> {
try {
await getConnection('test').close();
@ -73,8 +105,8 @@ export class SQLConnection {
// Adding enforced users to the db
const userRepository = connection.getRepository(UserEntity);
if (
Array.isArray(Config.Users.enforcedUsers) &&
Config.Users.enforcedUsers.length > 0
Array.isArray(Config.Users.enforcedUsers) &&
Config.Users.enforcedUsers.length > 0
) {
for (let i = 0; i < Config.Users.enforcedUsers.length; ++i) {
const uc = Config.Users.enforcedUsers[i];
@ -106,12 +138,12 @@ export class SQLConnection {
role: UserRoles.Admin,
});
if (
defAdmin &&
PasswordHelper.comparePassword('admin', defAdmin.password)
defAdmin &&
PasswordHelper.comparePassword('admin', defAdmin.password)
) {
NotificationManager.error(
'Using default admin user!',
'You are using the default admin/admin user/password, please change or remove it.'
'Using default admin user!',
'You are using the default admin/admin user/password, please change or remove it.'
);
}
}
@ -128,12 +160,39 @@ export class SQLConnection {
}
}
private static FIXED_SQL_TABLE = [
'sqlite_sequence'
];
/**
* Clears up the DB from unused tables. use it when the entities list are up-to-date (extensions won't add any new)
*/
public static async removeUnusedTables() {
const conn = await this.getConnection();
const validTableNames = this.entries.map(e => conn.getRepository(e).metadata.tableName).concat(this.FIXED_SQL_TABLE);
let currentTables: string[];
if (Config.Database.type === DatabaseType.sqlite) {
currentTables = (await conn.query('SELECT name FROM sqlite_master WHERE type=\'table\''))
.map((r: { name: string }) => r.name);
} else {
currentTables = (await conn.query(`SELECT table_name FROM information_schema.tables ` +
`WHERE table_schema = '${Config.Database.mysql.database}'`))
.map((r: { table_name: string }) => r.table_name);
}
const tableToDrop = currentTables.filter(ct => !validTableNames.includes(ct));
for (let i = 0; i < tableToDrop.length; ++i) {
await conn.query('DROP TABLE ' + tableToDrop[i]);
}
}
public static getSQLiteDB(config: ServerDataBaseConfig): string {
return path.join(ProjectPath.getAbsolutePath(config.dbFolder), 'sqlite.db');
}
private static async createConnection(
options: DataSourceOptions
options: DataSourceOptions
): Promise<Connection> {
if (options.type === 'sqlite' || options.type === 'better-sqlite3') {
return await createConnection(options);
@ -149,7 +208,7 @@ export class SQLConnection {
delete tmpOption.database;
const tmpConn = await createConnection(tmpOption);
await tmpConn.query(
'CREATE DATABASE IF NOT EXISTS ' + options.database
'CREATE DATABASE IF NOT EXISTS ' + options.database
);
await tmpConn.close();
return await createConnection(options);
@ -177,9 +236,9 @@ export class SQLConnection {
let users: UserEntity[] = [];
try {
users = await connection
.getRepository(UserEntity)
.createQueryBuilder('user')
.getMany();
.getRepository(UserEntity)
.createQueryBuilder('user')
.getMany();
// eslint-disable-next-line no-empty
} catch (ex) {
}
@ -193,9 +252,9 @@ export class SQLConnection {
await connection.synchronize();
await connection.getRepository(VersionEntity).save(version);
Logger.warn(
LOG_TAG,
'Could not move users to the new db scheme, deleting them. Details:' +
e.toString()
LOG_TAG,
'Could not move users to the new db scheme, deleting them. Details:' +
e.toString()
);
}
}
@ -217,26 +276,12 @@ export class SQLConnection {
driver = {
type: 'better-sqlite3',
database: path.join(
ProjectPath.getAbsolutePath(config.dbFolder),
config.sqlite.DBFileName
ProjectPath.getAbsolutePath(config.dbFolder),
config.sqlite.DBFileName
),
};
}
driver.entities = [
UserEntity,
FileEntity,
MDFileEntity,
PersonJunctionTable,
PersonEntry,
MediaEntity,
PhotoEntity,
VideoEntity,
DirectoryEntity,
SharingEntity,
AlbumBaseEntity,
SavedSearchEntity,
VersionEntity,
];
driver.entities = this.entries;
driver.synchronize = false;
if (Config.Server.Log.sqlLevel !== SQLLogLevel.none) {
driver.logging = SQLLogLevel[Config.Server.Log.sqlLevel] as LoggerOptions;

View File

@ -0,0 +1,79 @@
import * as express from 'express';
import {NextFunction, Request, Response} from 'express';
import {UserDTO, UserRoles} from '../../../common/entities/UserDTO';
import {AuthenticationMWs} from '../../middlewares/user/AuthenticationMWs';
import {RenderingMWs} from '../../middlewares/RenderingMWs';
import {ParamsDictionary} from 'express-serve-static-core';
import {IExtensionRESTApi, IExtensionRESTRoute} from './IExtension';
import {ILogger} from '../../Logger';
import {ExtensionManager} from './ExtensionManager';
import {Utils} from '../../../common/Utils';
export class ExpressRouterWrapper implements IExtensionRESTApi {
constructor(private readonly router: express.Router,
private readonly name: string,
private readonly extLogger: ILogger) {
}
get use() {
return new ExpressRouteWrapper(this.router, this.name, 'use', this.extLogger);
}
get get() {
return new ExpressRouteWrapper(this.router, this.name, 'get', this.extLogger);
}
get put() {
return new ExpressRouteWrapper(this.router, this.name, 'put', this.extLogger);
}
get post() {
return new ExpressRouteWrapper(this.router, this.name, 'post', this.extLogger);
}
get delete() {
return new ExpressRouteWrapper(this.router, this.name, 'delete', this.extLogger);
}
}
export class ExpressRouteWrapper implements IExtensionRESTRoute {
constructor(private readonly router: express.Router,
private readonly name: string,
private readonly func: 'get' | 'use' | 'put' | 'post' | 'delete',
private readonly extLogger: ILogger) {
}
private getAuthMWs(minRole: UserRoles) {
return minRole ? [AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(minRole)] : [];
}
public jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise<unknown> | unknown) {
const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p)));
this.router[this.func](fullPaths,
...(this.getAuthMWs(minRole).concat([
async (req: Request, res: Response, next: NextFunction) => {
req.resultPipe = await cb(req.params, req.body, req.session['user']);
next();
},
RenderingMWs.renderResult
])));
const p = ExtensionManager.EXTENSION_API_PATH + fullPaths;
this.extLogger.silly(`Listening on ${this.func} ${p}`);
return p;
}
public rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise<void>) {
const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p)));
this.router[this.func](fullPaths,
...this.getAuthMWs(minRole),
mw);
const p = ExtensionManager.EXTENSION_API_PATH + fullPaths;
this.extLogger.silly(`Listening on ${this.func} ${p}`);
return p;
}
}

View File

@ -0,0 +1,18 @@
import {IExtensionApp} from './IExtension';
import {ObjectManagers} from '../ObjectManagers';
import {Config} from '../../../common/config/private/Config';
import {Server} from '../../server';
export class ExtensionApp implements IExtensionApp {
get config() {
return Config;
}
get expressApp() {
return Server.getInstance().app;
}
get objectManagers() {
return ObjectManagers.getInstance();
}
}

View File

@ -0,0 +1,52 @@
import {IConfigClass} from 'typeconfig/common';
import {Config, PrivateConfigClass} from '../../../common/config/private/Config';
import {ConfigClassBuilder} from 'typeconfig/node';
import {IExtensionConfig} from './IExtension';
import {Utils} from '../../../common/Utils';
import {ObjectManagers} from '../ObjectManagers';
/**
* Wraps to original config and makes sure all extension related config is loaded
*/
export class ExtensionConfigWrapper {
static async original(): Promise<PrivateConfigClass & IConfigClass> {
const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass());
try {
await pc.load();
if (ObjectManagers.isReady()) {
for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) {
ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc));
}
}
} catch (e) {
console.error('Error during loading original config. Reverting to defaults.');
console.error(e);
}
return pc;
}
}
export class ExtensionConfig<C> implements IExtensionConfig<C> {
public template: new() => C;
constructor(private readonly extensionId: string) {
}
public getConfig(): C {
return Config.Extensions.configs[this.extensionId] as C;
}
public setTemplate(template: new() => C): void {
this.template = template;
this.loadToConfig(Config);
}
loadToConfig(config: PrivateConfigClass) {
if (!this.template) {
return;
}
const conf = ConfigClassBuilder.attachPrivateInterface(new this.template());
conf.__loadJSONObject(Utils.clone(config.Extensions.configs[this.extensionId] || {}));
config.Extensions.configs[this.extensionId] = conf;
}
}

View File

@ -0,0 +1,26 @@
import {IExtensionDB} from './IExtension';
import {SQLConnection} from '../database/SQLConnection';
import {Connection} from 'typeorm';
import {ILogger} from '../../Logger';
export class ExtensionDB implements IExtensionDB {
constructor(private readonly extLogger: ILogger) {
}
// eslint-disable-next-line @typescript-eslint/ban-types
_getAllTables(): Function[] {
return SQLConnection.getEntries();
}
getSQLConnection(): Promise<Connection> {
return SQLConnection.getConnection();
}
// eslint-disable-next-line @typescript-eslint/ban-types
async setExtensionTables(tables: Function[]): Promise<void> {
this.extLogger.debug('Adding ' + tables?.length + ' extension tables to DB');
await SQLConnection.addEntries(tables);
}
}

View File

@ -0,0 +1,40 @@
import {IExtensionEvent, IExtensionEvents} from './IExtension';
import {ExtensionEvent} from './ExtensionEvent';
export class ExtensionDecoratorObject {
public static events: IExtensionEvents;
static init(events: IExtensionEvents) {
this.events = events;
}
}
export const ExtensionDecorator = <I extends [], O>(fn: (ee: IExtensionEvents) => IExtensionEvent<I, O>) => {
return (
target: unknown,
propertyName: string,
descriptor: PropertyDescriptor
) => {
const targetMethod = descriptor.value;
descriptor.value = async function(...args: I) {
if (!ExtensionDecoratorObject.events) {
return await targetMethod.apply(this, args);
}
const event = fn(ExtensionDecoratorObject.events) as ExtensionEvent<I, O>;
const eventObj = {stopPropagation: false};
const input = await event.triggerBefore({inputs: args}, eventObj);
// skip the rest of the execution if the before handler asked for stop propagation
if (eventObj.stopPropagation) {
return input as O;
}
const out = await targetMethod.apply(this, args);
return await event.triggerAfter(out);
};
return descriptor;
};
};

View File

@ -0,0 +1,57 @@
import {IExtensionAfterEventHandler, IExtensionBeforeEventHandler, IExtensionEvent} from './IExtension';
export class ExtensionEvent<I, O> implements IExtensionEvent<I, O> {
protected beforeHandlers: IExtensionBeforeEventHandler<I, O>[] = [];
protected afterHandlers: IExtensionAfterEventHandler<O>[] = [];
public before(handler: IExtensionBeforeEventHandler<I, O>): void {
if (typeof handler !== 'function') {
throw new Error('ExtensionEvent::before: Handler is not a function');
}
this.beforeHandlers.push(handler);
}
public after(handler: IExtensionAfterEventHandler<O>): void {
if (typeof handler !== 'function') {
throw new Error('ExtensionEvent::after: Handler is not a function');
}
this.afterHandlers.push(handler);
}
public offAfter(handler: IExtensionAfterEventHandler<O>): void {
this.afterHandlers = this.afterHandlers.filter((h) => h !== handler);
}
public offBefore(handler: IExtensionBeforeEventHandler<I, O>): void {
this.beforeHandlers = this.beforeHandlers.filter((h) => h !== handler);
}
public async triggerBefore(input: { inputs: I }, event: { stopPropagation: boolean }): Promise<{ inputs: I } | O> {
let pipe: { inputs: I } | O = input;
if (this.beforeHandlers && this.beforeHandlers.length > 0) {
const s = this.beforeHandlers.slice(0);
for (let i = 0; i < s.length; ++i) {
if (event.stopPropagation) {
break;
}
pipe = await s[i](pipe as { inputs: I }, event);
}
}
return pipe;
}
public async triggerAfter(output: O): Promise<O> {
if (this.afterHandlers && this.afterHandlers.length > 0) {
const s = this.afterHandlers.slice(0);
for (let i = 0; i < s.length; ++i) {
output = await s[i](output);
}
}
return output;
}
}

View File

@ -0,0 +1,157 @@
import {ProjectPath} from '../../ProjectPath';
import {Config} from '../../../common/config/private/Config';
import * as fs from 'fs';
import * as path from 'path';
import {IObjectManager} from '../database/IObjectManager';
import {Logger} from '../../Logger';
import {IExtensionEvents, IExtensionObject} from './IExtension';
import {Server} from '../../server';
import {ExtensionEvent} from './ExtensionEvent';
import * as express from 'express';
import {SQLConnection} from '../database/SQLConnection';
import {ExtensionObject} from './ExtensionObject';
import {ExtensionDecoratorObject} from './ExtensionDecorator';
import * as util from 'util';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const exec = util.promisify(require('child_process').exec);
const LOG_TAG = '[ExtensionManager]';
export class ExtensionManager implements IObjectManager {
public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension';
events: IExtensionEvents;
extObjects: { [key: string]: ExtensionObject<unknown> } = {};
router: express.Router;
constructor() {
this.initEvents();
}
public async init() {
this.extObjects = {};
this.initEvents();
if (!Config.Extensions.enabled) {
return;
}
this.router = express.Router();
Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, this.router);
this.loadExtensionsList();
await this.initExtensions();
}
private initEvents() {
this.events = {
gallery: {
MetadataLoader: {
loadPhotoMetadata: new ExtensionEvent(),
loadVideoMetadata: new ExtensionEvent()
},
CoverManager: {
getCoverForDirectory: new ExtensionEvent(),
getCoverForAlbum: new ExtensionEvent(),
invalidateDirectoryCovers: new ExtensionEvent(),
},
DiskManager: {
scanDirectory: new ExtensionEvent()
},
ImageRenderer: {
render: new ExtensionEvent()
}
}
};
ExtensionDecoratorObject.init(this.events);
}
public loadExtensionsList() {
Logger.debug(LOG_TAG, 'Loading extension list from ' + ProjectPath.ExtensionFolder);
if (!fs.existsSync(ProjectPath.ExtensionFolder)) {
return;
}
Config.Extensions.list = fs
.readdirSync(ProjectPath.ExtensionFolder)
.filter((f): boolean =>
fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory()
);
Config.Extensions.list.sort();
Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list));
}
private createUniqueExtensionObject(name: string, folder: string): IExtensionObject<unknown> {
let id = name;
if (this.extObjects[id]) {
let i = 0;
while (this.extObjects[`${name}_${++i}`]) { /* empty */
}
id = `${name}_${++i}`;
}
if (!this.extObjects[id]) {
this.extObjects[id] = new ExtensionObject(id, name, folder, this.router, this.events);
}
return this.extObjects[id];
}
private async initExtensions() {
for (let i = 0; i < Config.Extensions.list.length; ++i) {
const extFolder = Config.Extensions.list[i];
let extName = extFolder;
const extPath = path.join(ProjectPath.ExtensionFolder, extFolder);
const serverExtPath = path.join(extPath, 'server.js');
const packageJsonPath = path.join(extPath, 'package.json');
if (!fs.existsSync(serverExtPath)) {
Logger.silly(LOG_TAG, `Skipping ${extFolder} server initiation. server.js does not exists`);
continue;
}
if (fs.existsSync(packageJsonPath)) {
Logger.silly(LOG_TAG, `Running: "npm install --prefer-offline --no-audit --progress=false --omit=dev" in ${extPath}`);
await exec('npm install --no-audit --progress=false --omit=dev', {
cwd: extPath
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require(packageJsonPath);
if (pkg.name) {
extName = pkg.name;
}
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ext = require(serverExtPath);
if (typeof ext?.init === 'function') {
Logger.debug(LOG_TAG, 'Running init on extension: ' + extFolder);
await ext?.init(this.createUniqueExtensionObject(extName, extPath));
}
}
if (Config.Extensions.cleanUpUnusedTables) {
// Clean up tables after all Extension was initialized.
await SQLConnection.removeUnusedTables();
}
}
private async cleanUpExtensions() {
for (const extObj of Object.values(this.extObjects)) {
const serverExt = path.join(extObj.folder, 'server.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ext = require(serverExt);
if (typeof ext?.cleanUp === 'function') {
Logger.debug(LOG_TAG, 'Running Init on extension:' + extObj.extensionName);
await ext?.cleanUp(extObj);
}
extObj.messengers.cleanUp();
}
}
public async cleanUp() {
if (!Config.Extensions.enabled) {
return;
}
this.initEvents(); // reset events
await this.cleanUpExtensions();
Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, express.Router());
this.extObjects = {};
}
}

View File

@ -0,0 +1,31 @@
import {IExtensionMessengers} from './IExtension';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {MediaDTOWithThPath, Messenger} from '../messenger/Messenger';
import {ExtensionMessenger} from '../messenger/ExtensionMessenger';
import {MessengerRepository} from '../messenger/MessengerRepository';
import {ILogger} from '../../Logger';
export class ExtensionMessengerHandler implements IExtensionMessengers {
messengers: Messenger[] = [];
constructor(private readonly extLogger: ILogger) {
}
addMessenger<C extends Record<string, unknown>>(name: string, config: DynamicConfig[], callbacks: {
sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise<void>
}): void {
this.extLogger.silly('Adding new Messenger:', name);
const em = new ExtensionMessenger(name, config, callbacks);
this.messengers.push(em);
MessengerRepository.Instance.register(em);
}
cleanUp() {
this.extLogger.silly('Removing Messenger');
this.messengers.forEach(m => MessengerRepository.Instance.remove(m));
}
}

View File

@ -0,0 +1,38 @@
import {IExtensionEvents, IExtensionObject} from './IExtension';
import {ExtensionApp} from './ExtensionApp';
import {ExtensionConfig} from './ExtensionConfigWrapper';
import {ExtensionDB} from './ExtensionDB';
import {ProjectPath} from '../../ProjectPath';
import {ExpressRouterWrapper} from './ExpressRouterWrapper';
import {createLoggerWrapper} from '../../Logger';
import * as express from 'express';
import {ExtensionMessengerHandler} from './ExtensionMessengerHandler';
export class ExtensionObject<C> implements IExtensionObject<C> {
public readonly _app;
public readonly config;
public readonly db;
public readonly paths;
public readonly Logger;
public readonly events;
public readonly RESTApi;
public readonly messengers;
constructor(public readonly extensionId: string,
public readonly extensionName: string,
public readonly folder: string,
extensionRouter: express.Router,
events: IExtensionEvents) {
const logger = createLoggerWrapper(`[Extension][${extensionId}]`);
this._app = new ExtensionApp();
this.config = new ExtensionConfig<C>(extensionId);
this.db = new ExtensionDB(logger);
this.paths = ProjectPath;
this.Logger = logger;
this.events = events;
this.RESTApi = new ExpressRouterWrapper(extensionRouter, extensionId, logger);
this.messengers = new ExtensionMessengerHandler(logger);
}
}

View File

@ -0,0 +1,199 @@
import * as express from 'express';
import {NextFunction, Request, Response} from 'express';
import {PrivateConfigClass} from '../../../common/config/private/Config';
import {ObjectManagers} from '../ObjectManagers';
import {ProjectPathClass} from '../../ProjectPath';
import {ILogger} from '../../Logger';
import {UserDTO, UserRoles} from '../../../common/entities/UserDTO';
import {ParamsDictionary} from 'express-serve-static-core';
import {Connection} from 'typeorm';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {MediaDTOWithThPath} from '../messenger/Messenger';
export type IExtensionBeforeEventHandler<I, O> = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>;
export type IExtensionAfterEventHandler<O> = (output: O) => Promise<O>;
export interface IExtensionEvent<I, O> {
before: (handler: IExtensionBeforeEventHandler<I, O>) => void;
after: (handler: IExtensionAfterEventHandler<O>) => void;
}
/**
* All main event callbacks in the app
*/
export interface IExtensionEvents {
gallery: {
/**
* Events for Directory and Album covers
*/
CoverManager: {
getCoverForAlbum: IExtensionEvent<any, any>;
getCoverForDirectory: IExtensionEvent<any, any>
/**
* Invalidates directory covers for a given directory and every parent
*/
invalidateDirectoryCovers: IExtensionEvent<any, any>;
},
ImageRenderer: {
/**
* Renders a thumbnail or photo
*/
render: IExtensionEvent<any, any>
},
/**
* Reads exif, iptc, etc.. metadata for photos/videos
*/
MetadataLoader: {
loadVideoMetadata: IExtensionEvent<any, any>,
loadPhotoMetadata: IExtensionEvent<any, any>
},
/**
* Scans the storage for a given directory and returns the list of child directories,
* photos, videos and metafiles
*/
DiskManager: {
scanDirectory: IExtensionEvent<any, any>
}
};
}
export interface IExtensionApp {
expressApp: express.Express;
objectManagers: ObjectManagers;
config: PrivateConfigClass;
}
export interface IExtensionRESTRoute {
/**
* Sends a pigallery2 standard JSON object with payload or error message back to the client.
* @param paths RESTapi path, relative to the extension base endpoint
* @param minRole set to null to omit auer check (ie make the endpoint public)
* @param cb function callback
* @return newly added REST api path
*/
jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise<unknown> | unknown): string;
/**
* Exposes a standard expressjs middleware
* @param paths RESTapi path, relative to the extension base endpoint
* @param minRole set to null to omit auer check (ie make the endpoint public)
* @param mw expressjs middleware
* @return newly added REST api path
*/
rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise<void>): string;
}
export interface IExtensionRESTApi {
use: IExtensionRESTRoute;
get: IExtensionRESTRoute;
post: IExtensionRESTRoute;
put: IExtensionRESTRoute;
delete: IExtensionRESTRoute;
}
export interface IExtensionDB {
/**
* Returns with a typeorm SQL connection
*/
getSQLConnection(): Promise<Connection>;
/**
* Adds SQL tables to typeorm
* @param tables
*/
// eslint-disable-next-line @typescript-eslint/ban-types
setExtensionTables(tables: Function[]): Promise<void>;
/**
* Exposes all tables. You can use this if you van to have a foreign key to a built in table.
* Use with caution. This exposes the app's internal working.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
_getAllTables(): Function[];
}
export interface IExtensionConfig<C> {
setTemplate(template: new() => C): void;
getConfig(): C;
}
export interface IExtensionMessengers {
/**
* Adds a new messenger that the user can select e.g.: for sending top pick photos
* @param name Name of the messenger (also used as id)
* @param config config metadata for this messenger
* @param callbacks messenger logic
*/
addMessenger<C extends Record<string, unknown> = Record<string, unknown>>(name: string, config: DynamicConfig[], callbacks: {
sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise<void>
}): void;
}
export interface IExtensionObject<C = void> {
/**
* ID of the extension that is internally used. By default, the name and ID matches if there is no collision.
*/
extensionId: string,
/**
* Name of the extension
*/
extensionName: string,
/**
* Inner functionality of the app. Use this with caution.
* If you want to go deeper than the standard exposed APIs, you can try doing so here.
*/
_app: IExtensionApp;
/**
* Create extension related configuration
*/
config: IExtensionConfig<C>;
/**
* Create new SQL tables and access SQL connection
*/
db: IExtensionDB;
/**
* Paths to the main components of the app.
*/
paths: ProjectPathClass;
/**
* Logger of the app
*/
Logger: ILogger;
/**
* Main app events. Use this change indexing, cover or serving gallery
*/
events: IExtensionEvents;
/**
* Use this to define REST calls related to the extension
*/
RESTApi: IExtensionRESTApi;
/**
* Object to manipulate messengers.
* Messengers are used to send messages (like emails) from the app.
* One type of message is a list of selected photos.
*/
messengers: IExtensionMessengers;
}
/**
* Extension interface. All extension is expected to implement and export these methods
*/
export interface IServerExtension<C> {
/**
* Extension init function. Extension should at minimum expose this function.
* @param extension
*/
init(extension: IExtensionObject<C>): Promise<void>;
cleanUp?: (extension: IExtensionObject<C>) => Promise<void>;
}

View File

@ -14,6 +14,7 @@ import {GPXProcessing} from './fileprocessing/GPXProcessing';
import {MDFileDTO} from '../../../common/entities/MDFileDTO';
import {MetadataLoader} from './MetadataLoader';
import {NotificationManager} from '../NotifocationManager';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
const LOG_TAG = '[DiskManager]';
@ -101,6 +102,7 @@ export class DiskManager {
)) as ParentDirectoryDTO<FileDTO>;
}
@ExtensionDecorator(e => e.gallery.DiskManager.scanDirectory)
public static async scanDirectory(
relativeDirectoryName: string,
settings: DirectoryScanSettings = {}

View File

@ -12,6 +12,7 @@ import {IptcParser} from 'ts-node-iptc';
import {FFmpegFactory} from '../FFmpegFactory';
import {FfprobeData} from 'fluent-ffmpeg';
import {Utils} from '../../../common/Utils';
import { ExtensionDecorator } from '../extension/ExtensionDecorator';
import * as exifr from 'exifr';
import * as path from 'path';
@ -19,6 +20,8 @@ const LOG_TAG = '[MetadataLoader]';
const ffmpeg = FFmpegFactory.get();
export class MetadataLoader {
@ExtensionDecorator(e=>e.gallery.MetadataLoader.loadVideoMetadata)
public static loadVideoMetadata(fullPath: string): Promise<VideoMetadata> {
return new Promise<VideoMetadata>((resolve) => {
const metadata: VideoMetadata = {
@ -153,6 +156,7 @@ export class MetadataLoader {
fileSize: 0,
};
@ExtensionDecorator(e=>e.gallery.MetadataLoader.loadPhotoMetadata)
public static loadPhotoMetadata(fullPath: string): Promise<PhotoMetadata> {
return new Promise<PhotoMetadata>((resolve, reject) => {
try {
@ -189,7 +193,7 @@ export class MetadataLoader {
fullPathWithoutExt + '.xmp',
fullPathWithoutExt + '.XMP',
];
for (const sidecarPath of sidecarPaths) {
if (fs.existsSync(sidecarPath)) {
const sidecarData = exifr.sidecar(sidecarPath);

View File

@ -5,6 +5,7 @@ import {Logger} from '../../Logger';
import {FfmpegCommand, FfprobeData} from 'fluent-ffmpeg';
import {FFmpegFactory} from '../FFmpegFactory';
import * as path from 'path';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
sharp.cache(false);
@ -129,6 +130,7 @@ export class VideoRendererFactory {
export class ImageRendererFactory {
@ExtensionDecorator(e=>e.gallery.ImageRenderer.render)
public static async render(input: MediaRendererInput | SvgRendererInput): Promise<void> {
let image: Sharp;

View File

@ -10,14 +10,15 @@ import {JobProgress} from './jobs/JobProgress';
import {JobProgressManager} from './JobProgressManager';
import {JobDTOUtils} from '../../../common/entities/job/JobDTO';
import {Utils} from '../../../common/Utils';
import {IObjectManager} from '../database/IObjectManager';
const LOG_TAG = '[JobManager]';
export class JobManager implements IJobListener {
export class JobManager implements IJobListener, IObjectManager {
protected timers: { schedule: JobScheduleDTO; timer: NodeJS.Timeout }[] = [];
protected progressManager: JobProgressManager = null;
constructor() {
async init(){
this.progressManager = new JobProgressManager();
this.runSchedules();
}
@ -49,7 +50,7 @@ export class JobManager implements IJobListener {
return prg;
}
public async run<T>(
public async run<T extends Record<string, unknown>>(
jobName: string,
config: T,
soloRun: boolean,
@ -85,7 +86,7 @@ export class JobManager implements IJobListener {
};
onJobFinished = async (
job: IJob<unknown>,
job: IJob,
state: JobProgressStates,
soloRun: boolean
): Promise<void> => {
@ -120,11 +121,16 @@ export class JobManager implements IJobListener {
}
};
getAvailableJobs(): IJob<unknown>[] {
getAvailableJobs(): IJob[] {
return JobRepository.Instance.getAvailableJobs();
}
public async cleanUp() {
this.stopSchedules();
}
public stopSchedules(): void {
Logger.silly(LOG_TAG, 'Stopping all schedules');
this.timers.forEach((t): void => clearTimeout(t.timer));
this.timers = [];
}
@ -138,7 +144,7 @@ export class JobManager implements IJobListener {
Config.Jobs.scheduled.forEach((s): void => this.runSchedule(s));
}
protected findJob<T = unknown>(jobName: string): IJob<T> {
protected findJob(jobName: string): IJob {
return this.getAvailableJobs().find((t): boolean => t.Name === jobName);
}

View File

@ -14,7 +14,7 @@ import {AlbumCoverRestJob} from './jobs/AlbumCoverResetJob';
export class JobRepository {
private static instance: JobRepository = null;
availableJobs: { [key: string]: IJob<unknown> } = {};
availableJobs: { [key: string]: IJob } = {};
public static get Instance(): JobRepository {
if (JobRepository.instance == null) {
@ -23,11 +23,11 @@ export class JobRepository {
return JobRepository.instance;
}
getAvailableJobs(): IJob<unknown>[] {
getAvailableJobs(): IJob[] {
return Object.values(this.availableJobs).filter((t) => t.Supported);
}
register(job: IJob<unknown>): void {
register(job: IJob): void {
if (typeof this.availableJobs[job.Name] !== 'undefined') {
throw new Error('Job already exist:' + job.Name);
}

View File

@ -1,10 +1,11 @@
import {ObjectManagers} from '../../ObjectManagers';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
export class AlbumCoverFillingJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Album Cover Filling']];
public readonly ConfigTemplate: ConfigTemplateEntry[] = null;
public readonly ConfigTemplate: DynamicConfig[] = null;
directoryToSetCover: { id: number; name: string; path: string }[] = null;
status: 'Persons' | 'Albums' | 'Directory' = 'Persons';
@ -20,7 +21,7 @@ export class AlbumCoverFillingJob extends Job {
if (!this.directoryToSetCover) {
this.Progress.log('Loading Directories to process');
this.directoryToSetCover =
await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers();
await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers();
this.Progress.Left = this.directoryToSetCover.length + 2;
return true;
}
@ -57,7 +58,7 @@ export class AlbumCoverFillingJob extends Job {
private async stepDirectoryCover(): Promise<boolean> {
if (this.directoryToSetCover.length === 0) {
this.directoryToSetCover =
await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers();
await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers();
// double check if there is really no more
if (this.directoryToSetCover.length > 0) {
return true; // continue
@ -70,7 +71,7 @@ export class AlbumCoverFillingJob extends Job {
this.Progress.Left = this.directoryToSetCover.length;
await ObjectManagers.getInstance().CoverManager.setAndGetCoverForDirectory(
directory
directory
);
this.Progress.Processed++;
return true;

View File

@ -1,10 +1,11 @@
import {ObjectManagers} from '../../ObjectManagers';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
export class AlbumCoverRestJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Album Cover Reset']];
public readonly ConfigTemplate: ConfigTemplateEntry[] = null;
public readonly ConfigTemplate: DynamicConfig[] = null;
protected readonly IsInstant = true;
public get Supported(): boolean {

View File

@ -1,10 +1,11 @@
import {ObjectManagers} from '../../ObjectManagers';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
export class AlbumRestJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Album Reset']];
public readonly ConfigTemplate: ConfigTemplateEntry[] = null;
public readonly ConfigTemplate: DynamicConfig[] = null;
protected readonly IsInstant = true;
public get Supported(): boolean {

View File

@ -1,4 +1,3 @@
import {ConfigTemplateEntry} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import * as path from 'path';
import {Logger} from '../../../Logger';
@ -13,6 +12,7 @@ import {ProjectPath} from '../../../ProjectPath';
import {FileEntity} from '../../database/enitites/FileEntity';
import {DirectoryBaseDTO, DirectoryDTOUtils} from '../../../../common/entities/DirectoryDTO';
import {DirectoryScanSettings, DiskManager} from '../../fileaccess/DiskManager';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
const LOG_TAG = '[FileJob]';
@ -20,7 +20,7 @@ const LOG_TAG = '[FileJob]';
* Abstract class for thumbnail creation, file deleting etc.
*/
export abstract class FileJob<S extends { indexedOnly?: boolean } = { indexedOnly?: boolean }> extends Job<S> {
public readonly ConfigTemplate: ConfigTemplateEntry[] = [];
public readonly ConfigTemplate: DynamicConfig[] = [];
directoryQueue: string[] = [];
fileQueue: string[] = [];
DBProcessing = {

View File

@ -1,10 +1,11 @@
import {ObjectManagers} from '../../ObjectManagers';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
export class GalleryRestJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Gallery Reset']];
public readonly ConfigTemplate: ConfigTemplateEntry[] = null;
public readonly ConfigTemplate: DynamicConfig[] = null;
protected readonly IsInstant = true;
public get Supported(): boolean {

View File

@ -2,7 +2,7 @@ import {JobDTO} from '../../../../common/entities/job/JobDTO';
import {JobProgress} from './JobProgress';
import {IJobListener} from './IJobListener';
export interface IJob<T> extends JobDTO {
export interface IJob<T extends Record<string, unknown> = Record<string, unknown>> extends JobDTO {
Name: string;
Supported: boolean;
Progress: JobProgress;

View File

@ -4,7 +4,7 @@ import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO'
export interface IJobListener {
onJobFinished(
job: IJob<unknown>,
job: IJob,
state: JobProgressStates,
soloRun: boolean
): void;

View File

@ -2,7 +2,7 @@ import {ObjectManagers} from '../../ObjectManagers';
import * as path from 'path';
import * as fs from 'fs';
import {Job} from './Job';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO';
import {ProjectPath} from '../../../ProjectPath';
import {backendTexts} from '../../../../common/BackendTexts';
@ -10,6 +10,7 @@ import {ParentDirectoryDTO} from '../../../../common/entities/DirectoryDTO';
import {Logger} from '../../../Logger';
import {FileDTO} from '../../../../common/entities/FileDTO';
import {DiskManager} from '../../fileaccess/DiskManager';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
const LOG_TAG = '[IndexingJob]';
@ -18,7 +19,7 @@ export class IndexingJob<
> extends Job<S> {
public readonly Name = DefaultsJobs[DefaultsJobs.Indexing];
directoriesToIndex: string[] = [];
public readonly ConfigTemplate: ConfigTemplateEntry[] = [
public readonly ConfigTemplate: DynamicConfig[] = [
{
id: 'indexChangesOnly',
type: 'boolean',

View File

@ -1,9 +1,10 @@
import {Logger} from '../../../Logger';
import {IJob} from './IJob';
import {ConfigTemplateEntry, JobDTO, JobDTOUtils,} from '../../../../common/entities/job/JobDTO';
import {JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO';
import {JobProgress} from './JobProgress';
import {IJobListener} from './IJobListener';
import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
declare const process: { nextTick: (_: unknown) => void };
declare const global: { gc: () => void };
@ -27,7 +28,7 @@ export abstract class Job<T extends Record<string, unknown> = Record<string, unk
public abstract get Name(): string;
public abstract get ConfigTemplate(): ConfigTemplateEntry[];
public abstract get ConfigTemplate(): DynamicConfig[];
public get Progress(): JobProgress {
return this.progress;

View File

@ -1,4 +1,4 @@
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import * as path from 'path';
import * as fs from 'fs';
import {Job} from './Job';
@ -6,10 +6,11 @@ import {ProjectPath} from '../../../ProjectPath';
import {GPXProcessing} from '../../fileaccess/fileprocessing/GPXProcessing';
import {PhotoProcessing} from '../../fileaccess/fileprocessing/PhotoProcessing';
import {VideoProcessing} from '../../fileaccess/fileprocessing/VideoProcessing';
import { DynamicConfig } from '../../../../common/entities/DynamicConfig';
export class TempFolderCleaningJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Temp Folder Cleaning']];
public readonly ConfigTemplate: ConfigTemplateEntry[] = null;
public readonly ConfigTemplate: DynamicConfig[] = null;
public readonly Supported: boolean = true;
directoryQueue: string[] = [];
private tempRootCleaned = false;

View File

@ -35,7 +35,7 @@ export class ThumbnailGenerationJob extends FileJob<{
): Promise<void> {
if (!config || !config.sizes || !Array.isArray(config.sizes) || config.sizes.length === 0) {
config = config || {};
config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue;
config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue as number[];
}
for (const item of config.sizes) {
if (Config.Media.Thumbnail.thumbnailSizes.indexOf(item) === -1) {

View File

@ -1,63 +1,67 @@
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultMessengers, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {backendTexts} from '../../../../common/BackendTexts';
import {SortByTypes} from '../../../../common/entities/SortingMethods';
import {DatePatternFrequency, DatePatternSearch, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO';
import {ObjectManagers} from '../../ObjectManagers';
import {PhotoEntity} from '../../database/enitites/PhotoEntity';
import {EmailMediaMessenger} from '../../mediamessengers/EmailMediaMessenger';
import {MediaPickDTO} from '../../../../common/entities/MediaPickDTO';
import {MediaDTOUtils} from '../../../../common/entities/MediaDTO';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
import {MessengerRepository} from '../../messenger/MessengerRepository';
import {Utils} from '../../../../common/Utils';
export class TopPickSendJob extends Job<{
mediaPick: MediaPickDTO[],
messenger: string,
emailTo: string,
emailFrom: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']];
public readonly Supported: boolean = true;
public readonly ConfigTemplate: ConfigTemplateEntry[] = [
{
id: 'mediaPick',
type: 'MediaPickDTO-array',
name: backendTexts.mediaPick.name,
description: backendTexts.mediaPick.description,
defaultValue: [{
searchQuery: {
type: SearchQueryTypes.date_pattern,
daysLength: 7,
frequency: DatePatternFrequency.every_year
} as DatePatternSearch,
sortBy: [{method: SortByTypes.Rating, ascending: false},
{method: SortByTypes.PersonCount, ascending: false}],
pick: 5
}] as MediaPickDTO[],
}, {
id: 'emailTo',
type: 'string-array',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: [],
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
},
];
public readonly ConfigTemplate: DynamicConfig[];
private status: 'Listing' | 'Sending' = 'Listing';
private mediaList: PhotoEntity[] = [];
constructor() {
super();
this.ConfigTemplate = [
{
id: 'mediaPick',
type: 'MediaPickDTO-array',
name: backendTexts.mediaPick.name,
description: backendTexts.mediaPick.description,
defaultValue: [{
searchQuery: {
type: SearchQueryTypes.date_pattern,
daysLength: 7,
frequency: DatePatternFrequency.every_year
} as DatePatternSearch,
sortBy: [{method: SortByTypes.Rating, ascending: false},
{method: SortByTypes.PersonCount, ascending: false}],
pick: 5
}] as MediaPickDTO[],
}, {
id: 'messenger',
type: 'messenger',
name: backendTexts.messenger.name,
description: backendTexts.messenger.description,
defaultValue: DefaultMessengers[DefaultMessengers.Email]
}
];
// add all messenger's config to the config template
MessengerRepository.Instance.getAll()
.forEach(msgr => Utils.clone(msgr.ConfigTemplate)
.forEach(ct => {
const c = Utils.clone(ct);
c.validIf = {configFiled: 'messenger', equalsValue: msgr.Name};
this.ConfigTemplate.push(c);
}));
}
protected async init(): Promise<void> {
this.status = 'Listing';
@ -85,15 +89,15 @@ export class TopPickSendJob extends Job<{
this.mediaList = [];
for (let i = 0; i < this.config.mediaPick.length; ++i) {
const media = await ObjectManagers.getInstance().SearchManager
.getNMedia(this.config.mediaPick[i].searchQuery, this.config.mediaPick[i].sortBy, this.config.mediaPick[i].pick);
.getNMedia(this.config.mediaPick[i].searchQuery, this.config.mediaPick[i].sortBy, this.config.mediaPick[i].pick);
this.Progress.log('Find ' + media.length + ' photos and videos from ' + (i + 1) + '. load');
this.mediaList = this.mediaList.concat(media);
}
// make the list unique
this.mediaList = this.mediaList
.filter((value, index, arr) =>
arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index);
.filter((value, index, arr) =>
arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index);
this.Progress.Processed++;
// console.log(this.mediaList);
@ -102,17 +106,16 @@ export class TopPickSendJob extends Job<{
private async stepSending(): Promise<boolean> {
if (this.mediaList.length <= 0) {
this.Progress.log('No photos found skipping e-mail sending.');
this.Progress.log('No photos found skipping sending.');
this.Progress.Skipped++;
return false;
}
this.Progress.log('Sending emails of ' + this.mediaList.length + ' photos.');
const messenger = new EmailMediaMessenger();
await messenger.sendMedia({
to: this.config.emailTo,
subject: this.config.emailSubject,
text: this.config.emailText
}, this.mediaList);
const msgr = MessengerRepository.Instance.get(this.config.messenger);
if (!msgr) {
throw new Error('Can\t find "' + this.config.messenger + '" messenger.');
}
this.Progress.log('Sending ' + this.mediaList.length + ' photos.');
await msgr.send(this.config, this.mediaList);
this.Progress.Processed++;
return false;
}

View File

@ -1,18 +1,40 @@
import {createTransport, Transporter} from 'nodemailer';
import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO';
import {Config} from '../../../common/config/private/Config';
import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing';
import {ThumbnailSourceType} from '../fileaccess/PhotoWorker';
import {ProjectPath} from '../../ProjectPath';
import * as path from 'path';
import {PhotoMetadata} from '../../../common/entities/PhotoDTO';
import {Utils} from '../../../common/Utils';
import {QueryParams} from '../../../common/QueryParams';
import {MediaDTOWithThPath, Messenger} from './Messenger';
import {backendTexts} from '../../../common/BackendTexts';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {DefaultMessengers} from '../../../common/entities/job/JobDTO';
export class EmailMediaMessenger {
export class EmailMessenger extends Messenger<{
emailTo: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultMessengers[DefaultMessengers.Email];
public readonly ConfigTemplate: DynamicConfig[] = [{
id: 'emailTo',
type: 'string-array',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: [],
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
}];
transporter: Transporter;
constructor() {
super();
this.transporter = createTransport({
host: Config.Messaging.Email.smtp.host,
port: Config.Messaging.Email.smtp.port,
@ -25,24 +47,16 @@ export class EmailMediaMessenger {
});
}
private async getThumbnail(m: MediaDTO) {
return await PhotoProcessing.generateThumbnail(
path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name),
Config.Media.Thumbnail.thumbnailSizes[0],
MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video,
false
);
}
public async sendMedia(mailSettings: {
to: string,
subject: string,
text: string
}, media: MediaDTO[]) {
protected async sendMedia(mailSettings: {
emailTo: string,
emailSubject: string,
emailText: string
}, media: MediaDTOWithThPath[]) {
const attachments = [];
const htmlStart = '<h1 style="text-align: center; margin-bottom: 2em">' + Config.Server.applicationTitle + '</h1>\n' +
'<h3>' + mailSettings.text + '</h3>\n' +
'<h3>' + mailSettings.emailText + '</h3>\n' +
'<table style="margin-left: auto; margin-right: auto;">\n' +
' <tbody>\n';
const htmlEnd = ' </tr>\n' +
@ -51,9 +65,6 @@ export class EmailMediaMessenger {
let htmlMiddle = '';
const numberOfColumns = media.length >= 6 ? 3 : 2;
for (let i = 0; i < media.length; ++i) {
const thPath = await this.getThumbnail(media[i]);
const linkUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) +
'?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name);
const location = (media[i].metadata as PhotoMetadata).positionData?.country ?
(media[i].metadata as PhotoMetadata).positionData?.country :
((media[i].metadata as PhotoMetadata).positionData?.city ?
@ -61,14 +72,14 @@ export class EmailMediaMessenger {
const caption = (new Date(media[i].metadata.creationDate)).getFullYear() + (location ? ', ' + location : '');
attachments.push({
filename: media[i].name,
path: thPath,
path: media[i].thumbnailPath,
cid: 'img' + i
});
if (i % numberOfColumns == 0) {
htmlMiddle += '<tr>';
}
htmlMiddle += '<td>\n' +
' <a style="display: block;text-align: center;" href="' + linkUrl + '"><img alt="' + media[i].name + '" style="max-width: 200px; max-height: 150px; height:auto; width:auto;" src="cid:img' + i + '"/></a>\n' +
' <a style="display: block;text-align: center;" href="' + media[i].thumbnailUrl + '"><img alt="' + media[i].name + '" style="max-width: 200px; max-height: 150px; height:auto; width:auto;" src="cid:img' + i + '"/></a>\n' +
caption +
' </td>\n';
@ -79,8 +90,8 @@ export class EmailMediaMessenger {
return await this.transporter.sendMail({
from: Config.Messaging.Email.emailFrom,
to: mailSettings.to,
subject: mailSettings.subject,
to: mailSettings.emailTo,
subject: mailSettings.emailSubject,
html: htmlStart + htmlMiddle + htmlEnd,
attachments: attachments
});

View File

@ -0,0 +1,15 @@
import {MediaDTOWithThPath, Messenger} from './Messenger';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
export class ExtensionMessenger<C extends Record<string, unknown> = Record<string, unknown>> extends Messenger<C> {
constructor(public readonly Name: string,
public readonly ConfigTemplate: DynamicConfig[],
private readonly callbacks: { sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise<void> }) {
super();
}
protected sendMedia(config: C, media: MediaDTOWithThPath[]): Promise<void> {
return this.callbacks.sendMedia(config, media);
}
}

View File

@ -0,0 +1,50 @@
import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO';
import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing';
import {ProjectPath} from '../../ProjectPath';
import {Config} from '../../../common/config/private/Config';
import {ThumbnailSourceType} from '../fileaccess/PhotoWorker';
import * as path from 'path';
import {Utils} from '../../../common/Utils';
import {QueryParams} from '../../../common/QueryParams';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
export interface MediaDTOWithThPath extends MediaDTO {
thumbnailPath: string;
thumbnailUrl: string;
}
export abstract class Messenger<C extends Record<string, unknown> = Record<string, unknown>> {
public abstract get Name(): string;
protected config: C;
public readonly ConfigTemplate: DynamicConfig[] = [];
private async getThumbnail(m: MediaDTO) {
return await PhotoProcessing.generateThumbnail(
path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name),
Config.Media.Thumbnail.thumbnailSizes[0],
MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video,
false
);
}
public async send(config: C, input: string | MediaDTO[] | unknown) {
if (Array.isArray(input) && input.length > 0
&& (input as MediaDTO[])[0]?.name
&& (input as MediaDTO[])[0]?.directory
&& (input as MediaDTO[])[0]?.metadata?.creationDate) {
const media = input as MediaDTOWithThPath[];
for (let i = 0; i < media.length; ++i) {
media[i].thumbnailPath = await this.getThumbnail(media[i]);
media[i].thumbnailUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) +
'?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name);
}
return await this.sendMedia(config, media);
}
// TODO: implement other branches
throw new Error('Not yet implemented');
}
protected abstract sendMedia(config: C, media: MediaDTOWithThPath[]): Promise<void> ;
}

View File

@ -0,0 +1,41 @@
import {Messenger} from './Messenger';
import {EmailMessenger} from './EmailMessenger';
import {StdoutMessenger} from './StdoutMessenger';
export class MessengerRepository {
private static instance: MessengerRepository = null;
messengers: { [key: string]: Messenger } = {};
public static get Instance(): MessengerRepository {
if (MessengerRepository.instance == null) {
MessengerRepository.instance = new MessengerRepository();
}
return MessengerRepository.instance;
}
getAll(): Messenger[] {
return Object.values(this.messengers);
}
remove(m: Messenger<Record<string, unknown>>): void {
if (!this.messengers[m.Name]) {
throw new Error('Messenger does not exist:' + m.Name);
}
delete this.messengers[m.Name];
}
register(msgr: Messenger): void {
if (typeof this.messengers[msgr.Name] !== 'undefined') {
throw new Error('Messenger already exist:' + msgr.Name);
}
this.messengers[msgr.Name] = msgr;
}
get(name: string): Messenger {
return this.messengers[name];
}
}
MessengerRepository.Instance.register(new EmailMessenger());
MessengerRepository.Instance.register(new StdoutMessenger());

View File

@ -0,0 +1,17 @@
import {MediaDTOWithThPath, Messenger} from './Messenger';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {DefaultMessengers} from '../../../common/entities/job/JobDTO';
export class StdoutMessenger extends Messenger {
public readonly Name = DefaultMessengers[DefaultMessengers.Stdout];
public readonly ConfigTemplate: DynamicConfig[] = [];
constructor() {
super();
}
protected async sendMedia(config: never, media: MediaDTOWithThPath[]) {
console.log(media.map(m => m.thumbnailPath));
}
}

View File

@ -10,6 +10,7 @@ export class AdminRouter {
this.addGetStatistic(app);
this.addGetDuplicates(app);
this.addJobs(app);
this.addMessengers(app);
}
private static addGetStatistic(app: Express): void {
@ -32,6 +33,15 @@ export class AdminRouter {
);
}
private static addMessengers(app: Express): void {
app.get(
Config.Server.apiPath + '/admin/messengers/available',
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.Admin),
AdminMWs.getAvailableMessengers,
RenderingMWs.renderResult
);
}
private static addJobs(app: Express): void {
app.get(
Config.Server.apiPath + '/admin/jobs/available',

View File

@ -32,9 +32,18 @@ const LOG_TAG = '[server]';
export class Server {
public onStarted = new Event<void>();
private app: express.Express;
public app: express.Express;
private server: HttpServer;
static instance: Server = null;
public static getInstance(): Server {
if (!this.instance) {
this.instance = new Server();
}
return this.instance;
}
constructor() {
if (!(process.env.NODE_ENV === 'production')) {
Logger.info(
@ -45,11 +54,16 @@ export class Server {
this.init().catch(console.error);
}
get App(): any {
get Server(): HttpServer {
return this.server;
}
async init(): Promise<void> {
this.app = express();
LoggerRouter.route(this.app);
this.app.set('view engine', 'ejs');
Logger.info(LOG_TAG, 'running diagnostics...');
await ConfigDiagnostics.runDiagnostics();
Logger.verbose(
@ -61,13 +75,14 @@ export class Server {
).configPath +
':'
);
Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), null, '\t'));
Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), (k, v) => {
const MAX_LENGTH = 80;
if (typeof v === 'string' && v.length > MAX_LENGTH) {
v = v.slice(0, MAX_LENGTH - 3) + '...';
}
return v;
}, 2));
this.app = express();
LoggerRouter.route(this.app);
this.app.set('view engine', 'ejs');
/**
* Session above all
@ -115,7 +130,7 @@ export class Server {
Localizations.init();
this.app.use(locale(Config.Server.languages, 'en'));
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
Router.route(this.app);

View File

@ -1,4 +1,5 @@
export type backendText = number;
// keep the numbering sparse to support later addition
export const backendTexts = {
indexedFilesOnly: {name: 10, description: 12},
sizeToGenerate: {name: 20, description: 22},
@ -6,6 +7,7 @@ export const backendTexts = {
mediaPick: {name: 40, description: 42},
emailTo: {name: 70, description: 72},
emailSubject: {name: 90, description: 92},
emailText: {name: 100, description: 102}
emailText: {name: 100, description: 102},
messenger: {name: 110,description: 112}
};

View File

@ -13,7 +13,7 @@ const upTime = new Date().toISOString();
// TODO: Refactor Config to be injectable globally.
// This is a bad habit to let the Config know if its in a testing env.
const isTesting = process.env['NODE_ENV'] == true || ['afterEach', 'after', 'beforeEach', 'before', 'describe', 'it']
.every((fn) => (global as any)[fn] instanceof Function);
.every((fn) => (global as any)[fn] instanceof Function);
@ConfigClass<IConfigClass<TAGS> & ServerConfig>({
configPath: path.join(__dirname, !isTesting ? './../../../../config.json' : './../../../../test/backend/tmp/config.json'),
@ -76,30 +76,20 @@ export class PrivateConfigClass extends ServerConfig {
}
this.Environment.appVersion =
require('../../../../package.json').version;
require('../../../../package.json').version;
this.Environment.buildTime =
require('../../../../package.json').buildTime;
require('../../../../package.json').buildTime;
this.Environment.buildCommitHash =
require('../../../../package.json').buildCommitHash;
require('../../../../package.json').buildCommitHash;
this.Environment.upTime = upTime;
this.Environment.isDocker = !!process.env.PI_DOCKER;
}
async original(): Promise<PrivateConfigClass & IConfigClass> {
const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass());
try {
await pc.load();
} catch (e) {
console.error('Error during loading original config. Reverting to defaults.');
console.error(e);
}
return pc;
}
}
export const Config = ConfigClassBuilder.attachInterface(
new PrivateConfigClass()
new PrivateConfigClass()
);
try {
Config.loadSync();

View File

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-inferrable-types */
import {SubConfigClass} from '../../../../node_modules/typeconfig/src/decorators/class/SubConfigClass';
import {ConfigProperty, SubConfigClass} from 'typeconfig/common';
import {ConfigPriority, TAGS} from '../public/ClientConfig';
import {ConfigProperty} from '../../../../node_modules/typeconfig/src/decorators/property/ConfigPropoerty';
declare let $localize: (s: TemplateStringsArray) => string;

View File

@ -11,6 +11,7 @@ import {
} from '../../entities/job/JobScheduleDTO';
import {
ClientConfig,
ClientExtensionsConfig,
ClientGPXCompressingConfig,
ClientMediaConfig,
ClientMetaFileConfig,
@ -25,8 +26,7 @@ import {
ConfigPriority,
TAGS
} from '../public/ClientConfig';
import {SubConfigClass} from 'typeconfig/src/decorators/class/SubConfigClass';
import {ConfigProperty} from 'typeconfig/src/decorators/property/ConfigPropoerty';
import {ConfigProperty, SubConfigClass} from 'typeconfig/common';
import {DefaultsJobs} from '../../entities/job/JobDTO';
import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/SearchQueryDTO';
import {SortByTypes} from '../../entities/SortingMethods';
@ -1013,6 +1013,25 @@ export class ServerServiceConfig extends ClientServiceConfig {
Log: ServerLogConfig = new ServerLogConfig();
}
@SubConfigClass<TAGS>({softReadonly: true})
export class ServerExtensionsConfig extends ClientExtensionsConfig {
@ConfigProperty({volatile: true})
list: string[] = [];
@ConfigProperty({type: 'object'})
configs: Record<string, unknown> = {};
@ConfigProperty({
tags: {
name: $localize`Clean up unused tables`,
priority: ConfigPriority.underTheHood,
},
description: $localize`Automatically removes all tables from the DB that are not used anymore.`,
})
cleanUpUnusedTables: boolean = true;
}
@SubConfigClass({softReadonly: true})
export class ServerEnvironmentConfig {
@ConfigProperty({volatile: true})
@ -1133,6 +1152,15 @@ export class ServerConfig extends ClientConfig {
})
Messaging: MessagingConfig = new MessagingConfig();
@ConfigProperty({
tags: {
name: $localize`Extensions`,
uiIcon: 'ionCloudOutline'
} as TAGS,
})
Extensions: ServerExtensionsConfig = new ServerExtensionsConfig();
@ConfigProperty({
tags: {
name: $localize`Jobs`,

View File

@ -1424,6 +1424,16 @@ export class ClientUserConfig {
unAuthenticatedUserRole: UserRoles = UserRoles.Admin;
}
@SubConfigClass({tags: {client: true}, softReadonly: true})
export class ClientExtensionsConfig {
@ConfigProperty({
tags: {
name: $localize`Enabled`,
priority: ConfigPriority.advanced,
}
})
enabled: boolean = true;
}
@SubConfigClass<TAGS>({tags: {client: true}, softReadonly: true})
export class ClientConfig {
@ -1496,4 +1506,13 @@ export class ClientConfig {
description: $localize`This feature enables you to generate 'random photo' urls. That URL returns a photo random selected from your gallery. You can use the url with 3rd party application like random changing desktop background. Note: With the current implementation, random link also requires login.`
})
RandomPhoto: ClientRandomPhotoConfig = new ClientRandomPhotoConfig();
@ConfigProperty({
tags: {
name: $localize`Extensions`,
uiIcon: 'ionCloudOutline'
} as TAGS,
})
Extensions: ClientExtensionsConfig = new ClientExtensionsConfig();
}

View File

@ -0,0 +1,21 @@
import {backendText} from '../BackendTexts';
export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array' | 'messenger';
/**
* Dynamic configs are not part of the typeconfig maintained config.
* Pigallery uses them to dynamically define configuration
* on the serverside so the client can parse and render it.
* It is mostly used for configuring jobs
*/
export interface DynamicConfig {
id: string;
// Use a predefined and localized backend text id or explicitly define the text
name: backendText | string;
description: backendText | string;
type: fieldType;
defaultValue: unknown;
validIf?: { configFiled: string, equalsValue: string }; // only shows this config if this predicate is true
}

View File

@ -1,6 +1,4 @@
import {backendText} from '../../BackendTexts';
export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array';
import {DynamicConfig} from '../DynamicConfig';
export enum DefaultsJobs {
Indexing = 1,
@ -17,17 +15,16 @@ export enum DefaultsJobs {
'Top Pick Sending' = 12
}
export interface ConfigTemplateEntry {
id: string;
name: backendText;
description: backendText;
type: fieldType;
defaultValue: any;
export enum DefaultMessengers {
Email = 1,
Stdout = 2
}
export interface JobDTO {
Name: string;
ConfigTemplate: ConfigTemplateEntry[];
ConfigTemplate: DynamicConfig[];
}
export const JobDTOUtils = {

View File

@ -5,7 +5,10 @@ import {DefaultsJobs} from '../../../common/entities/job/JobDTO';
@Injectable()
export class BackendtextService {
public get(id: backendText): string {
public get(id: backendText | string): string {
if (typeof id === 'string') {
return id;
}
switch (id) {
case backendTexts.sizeToGenerate.name:
return $localize`Size to generate`;
@ -35,6 +38,10 @@ export class BackendtextService {
return $localize`Message`;
case backendTexts.emailText.description:
return $localize`E-mail text.`;
case backendTexts.messenger.name:
return $localize`Messenger`;
case backendTexts.messenger.description:
return $localize`Messenger to send this message with.`;
default:
return null;
}

View File

@ -9,7 +9,7 @@ import {ConfigStyle, SettingsService} from '../settings/settings.service';
import {ConfigPriority} from '../../../../common/config/public/ClientConfig';
import {WebConfig} from '../../../../common/config/private/WebConfig';
import {ISettingsComponent} from '../settings/template/ISettingsComponent';
import {WebConfigClassBuilder} from '../../../../../node_modules/typeconfig/src/decorators/builders/WebConfigClassBuilder';
import {WebConfigClassBuilder} from 'typeconfig/src/decorators/builders/WebConfigClassBuilder';
import {enumToTranslatedArray} from '../EnumTranslations';
import {PiTitleService} from '../../model/pi-title.service';

View File

@ -1,6 +1,5 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {Component, EventEmitter, Input, Output,OnChanges} from '@angular/core';
import {BlogService, GroupedMarkdown} from './blog.service';
import {OnChanges} from '../../../../../../node_modules/@angular/core';
import {map, Observable} from 'rxjs';
@Component({

View File

@ -4,7 +4,7 @@ import {AutoCompleteService} from '../autocomplete.service';
import {SearchQueryDTO} from '../../../../../../common/entities/SearchQueryDTO';
import {ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator,} from '@angular/forms';
import {SearchQueryParserService} from '../search-query-parser.service';
import {BsModalRef, BsModalService,} from '../../../../../../../node_modules/ngx-bootstrap/modal';
import {BsModalRef, BsModalService,} from 'ngx-bootstrap/modal';
import {Utils} from '../../../../../../common/Utils';
@Component({

View File

@ -3,9 +3,10 @@ import {BehaviorSubject} from 'rxjs';
import {JobProgressDTO, JobProgressStates, OnTimerJobProgressDTO,} from '../../../../common/entities/job/JobProgressDTO';
import {NetworkService} from '../../model/network/network.service';
import {JobScheduleDTO} from '../../../../common/entities/job/JobScheduleDTO';
import {ConfigTemplateEntry, JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO';
import {JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO';
import {BackendtextService} from '../../model/backendtext.service';
import {NotificationService} from '../../model/notification.service';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
@Injectable()
export class ScheduledJobsService {
@ -13,6 +14,7 @@ export class ScheduledJobsService {
public onJobFinish: EventEmitter<string> = new EventEmitter<string>();
timer: number = null;
public availableJobs: BehaviorSubject<JobDTO[]>;
public availableMessengers: BehaviorSubject<string[]>;
public jobStartingStopping: { [key: string]: boolean } = {};
private subscribers = 0;
@ -23,6 +25,7 @@ export class ScheduledJobsService {
) {
this.progress = new BehaviorSubject({});
this.availableJobs = new BehaviorSubject([]);
this.availableMessengers = new BehaviorSubject([]);
}
@ -32,7 +35,13 @@ export class ScheduledJobsService {
);
}
public getConfigTemplate(JobName: string): ConfigTemplateEntry[] {
public async getAvailableMessengers(): Promise<void> {
this.availableMessengers.next(
await this.networkService.getJson<string[]>('/admin/messengers/available')
);
}
public getConfigTemplate(JobName: string): DynamicConfig[] {
const job = this.availableJobs.value.find(
(t) => t.Name === JobName
);

View File

@ -10,7 +10,7 @@ import {CookieService} from 'ngx-cookie-service';
import {DefaultsJobs} from '../../../../common/entities/job/JobDTO';
import {StatisticDTO} from '../../../../common/entities/settings/StatisticDTO';
import {ScheduledJobsService} from './scheduled-jobs.service';
import {IWebConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass';
import {IWebConfigClassPrivate} from 'typeconfig/src/decorators/class/IWebConfigClass';
export enum ConfigStyle {

View File

@ -16,7 +16,7 @@ import {ConfigStyle, SettingsService} from '../../settings.service';
import {WebConfig} from '../../../../../../common/config/private/WebConfig';
import {JobScheduleConfig, UserConfig} from '../../../../../../common/config/private/PrivateConfig';
import {enumToTranslatedArray} from '../../../EnumTranslations';
import {BsModalService} from '../../../../../../../node_modules/ngx-bootstrap/modal';
import {BsModalService} from 'ngx-bootstrap/modal';
import {CustomSettingsEntries} from '../CustomSettingsEntries';
import {GroupByTypes, SortByTypes} from '../../../../../../common/entities/SortingMethods';
@ -59,7 +59,7 @@ interface IState {
],
})
export class SettingsEntryComponent
implements ControlValueAccessor, Validator, OnChanges {
implements ControlValueAccessor, Validator, OnChanges {
name: string;
required: boolean;
dockerWarning: boolean;
@ -79,7 +79,10 @@ export class SettingsEntryComponent
public arrayType: string;
public uiType: string;
newThemeModalRef: any;
iconModal: { ref?: any, error?: string };
iconModal: {
ref?: any,
error?: string
};
@Input() noChangeDetection = false;
public readonly ConfigStyle = ConfigStyle;
protected readonly SortByTypes = SortByTypes;
@ -101,9 +104,9 @@ export class SettingsEntryComponent
for (let i = 0; i < this.state.value?.length; ++i) {
for (const k of Object.keys(this.state.value[i].__state)) {
if (!Utils.equalsFilter(
this.state.value[i]?.__state[k]?.value,
this.state.default[i] ? this.state.default[i][k] : undefined,
['default', '__propPath', '__created', '__prototype', '__rootConfig'])) {
this.state.value[i]?.__state[k]?.value,
this.state.default[i] ? this.state.default[i][k] : undefined,
['default', '__propPath', '__created', '__prototype', '__rootConfig'])) {
return true;
}
@ -129,7 +132,7 @@ export class SettingsEntryComponent
get defaultStr(): string {
if (this.type === 'SearchQuery') {
return (
'\'' + this.searchQueryParserService.stringify(this.state.default) + '\''
'\'' + this.searchQueryParserService.stringify(this.state.default) + '\''
);
}
@ -143,8 +146,8 @@ export class SettingsEntryComponent
get StringValue(): string {
if (
this.state.type === 'array' &&
(this.state.arrayType === 'string' || this.isNumberArray)
this.state.type === 'array' &&
(this.state.arrayType === 'string' || this.isNumberArray)
) {
return (this.state.value || []).join(';');
}
@ -162,8 +165,8 @@ export class SettingsEntryComponent
set StringValue(value: string) {
if (
this.state.type === 'array' &&
(this.state.arrayType === 'string' || this.isNumberArray)
this.state.type === 'array' &&
(this.state.arrayType === 'string' || this.isNumberArray)
) {
value = value.replace(new RegExp(',', 'g'), ';');
if (!this.allowSpaces) {
@ -172,14 +175,14 @@ export class SettingsEntryComponent
this.state.value = value.split(';').filter((v: string) => v !== '');
if (this.isNumberArray) {
this.state.value = this.state.value
.map((v: string) => parseFloat(v))
.filter((v: number) => !isNaN(v));
.map((v: string) => parseFloat(v))
.filter((v: number) => !isNaN(v));
}
return;
}
if (typeof this.state.value === 'object') {
this.state.value = JSON.parse(value);
return;
}
this.state.value = value;
@ -194,11 +197,13 @@ export class SettingsEntryComponent
key: 'default',
value: $localize`default`
}, ...(this.state.rootConfig as any).__state.availableThemes.value
.map((th: ThemeConfig) => ({key: th.name, value: th.name}))];
.map((th: ThemeConfig) => ({key: th.name, value: th.name}))];
}
get SelectedThemeSettings(): { theme: string } {
get SelectedThemeSettings(): {
theme: string
} {
return (this.state.value as ThemeConfig[]).find(th => th.name === (this.state.rootConfig as any).__state.selectedTheme.value) || {theme: 'N/A'};
}
@ -241,16 +246,16 @@ export class SettingsEntryComponent
this.uiType = CustomSettingsEntries.getFullName(this.state);
}
if (!this.state.isEnumType &&
!this.state.isEnumArrayType &&
this.type !== 'boolean' &&
this.type !== 'SearchQuery' &&
!CustomSettingsEntries.iS(this.state) &&
this.arrayType !== 'MapLayers' &&
this.arrayType !== 'NavigationLinkConfig' &&
this.arrayType !== 'MapPathGroupConfig' &&
this.arrayType !== 'MapPathGroupThemeConfig' &&
this.arrayType !== 'JobScheduleConfig' &&
this.arrayType !== 'UserConfig') {
!this.state.isEnumArrayType &&
this.type !== 'boolean' &&
this.type !== 'SearchQuery' &&
!CustomSettingsEntries.iS(this.state) &&
this.arrayType !== 'MapLayers' &&
this.arrayType !== 'NavigationLinkConfig' &&
this.arrayType !== 'MapPathGroupConfig' &&
this.arrayType !== 'MapPathGroupThemeConfig' &&
this.arrayType !== 'JobScheduleConfig' &&
this.arrayType !== 'UserConfig') {
this.uiType = 'StringInput';
}
if (this.type === this.state.tags?.uiType) {
@ -273,18 +278,18 @@ export class SettingsEntryComponent
this.name = this.state?.tags?.name;
if (this.name) {
this.idName =
this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-');
this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-');
}
this.isNumberArray =
this.state.arrayType === 'unsignedInt' ||
this.state.arrayType === 'integer' ||
this.state.arrayType === 'float' ||
this.state.arrayType === 'positiveFloat';
this.state.arrayType === 'unsignedInt' ||
this.state.arrayType === 'integer' ||
this.state.arrayType === 'float' ||
this.state.arrayType === 'positiveFloat';
this.isNumber =
this.state.type === 'unsignedInt' ||
this.state.type === 'integer' ||
this.state.type === 'float' ||
this.state.type === 'positiveFloat';
this.state.type === 'unsignedInt' ||
this.state.type === 'integer' ||
this.state.type === 'float' ||
this.state.type === 'positiveFloat';
if (this.isNumber) {
@ -306,11 +311,16 @@ export class SettingsEntryComponent
}
}
getOptionsView(state: IState & { optionsView?: { key: number | string; value: string | number }[] }) {
getOptionsView(state: IState & {
optionsView?: {
key: number | string;
value: string | number
}[]
}) {
if (!state.optionsView) {
const eClass = state.isEnumType
? state.type
: state.arrayType;
? state.type
: state.arrayType;
if (state.tags?.uiOptions) {
state.optionsView = state.tags?.uiOptions.map(o => ({
key: o,
@ -325,11 +335,11 @@ export class SettingsEntryComponent
validate(): ValidationErrors {
if (
!this.required ||
(this.state &&
typeof this.state.value !== 'undefined' &&
this.state.value !== null &&
this.state.value !== '')
!this.required ||
(this.state &&
typeof this.state.value !== 'undefined' &&
this.state.value !== null &&
this.state.value !== '')
) {
return null;
}
@ -386,8 +396,8 @@ export class SettingsEntryComponent
removeLayer(layer: MapLayers): void {
this.state.value.splice(
this.state.value.indexOf(layer),
1
this.state.value.indexOf(layer),
1
);
}
@ -395,7 +405,7 @@ export class SettingsEntryComponent
addNewTheme(): void {
const availableThemes = (this.state.rootConfig as any).__state.availableThemes;
if (!this.newThemeName ||
(availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) {
(availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) {
return;
}
this.state.value = this.newThemeName;

View File

@ -5,12 +5,12 @@ import {
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
} from '../../../../../../../../node_modules/@angular/forms';
} from '@angular/forms';
import {SortByDirectionalTypes, SortingMethod} from '../../../../../../../common/entities/SortingMethods';
import {enumToTranslatedArray} from '../../../../EnumTranslations';
import {AutoCompleteService} from '../../../../gallery/search/autocomplete.service';
import {RouterLink} from '../../../../../../../../node_modules/@angular/router';
import {forwardRef} from '../../../../../../../../node_modules/@angular/core';
import {RouterLink} from '@angular/router';
import {forwardRef} from '@angular/core';
import {Utils} from '../../../../../../../common/Utils';
@Component({

View File

@ -9,11 +9,11 @@ import {JobDTOUtils} from '../../../../../common/entities/job/JobDTO';
import {ScheduledJobsService} from '../scheduled-jobs.service';
import {UntypedFormControl} from '@angular/forms';
import {Subscription} from 'rxjs';
import {IWebConfigClassPrivate} from '../../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass';
import {IWebConfigClassPrivate} from 'typeconfig/src/decorators/class/IWebConfigClass';
import {ConfigPriority, TAGS} from '../../../../../common/config/public/ClientConfig';
import {Utils} from '../../../../../common/Utils';
import {UserRoles} from '../../../../../common/entities/UserDTO';
import {WebConfigClassBuilder} from '../../../../../../node_modules/typeconfig/src/decorators/builders/WebConfigClassBuilder';
import {WebConfigClassBuilder} from 'typeconfig/web';
import {ErrorDTO} from '../../../../../common/entities/Error';
import {ISettingsComponent} from './ISettingsComponent';
import {CustomSettingsEntries} from './CustomSettingsEntries';

View File

@ -170,6 +170,7 @@
<div *ngFor="let configEntry of jobsService.getConfigTemplate(schedule.jobName)">
<div class="mb-1 row"
*ngIf="!configEntry.validIf || schedule.config[configEntry.validIf.configFiled] == configEntry.validIf.equalsValue"
[class.mb-3]="settingsService.configStyle == ConfigStyle.full">
<label class="col-md-2 control-label"
[for]="configEntry.id+'_'+i">{{backendTextService.get(configEntry.name)}}</label>
@ -227,6 +228,18 @@
placeholder="Search Query">
</app-gallery-search-field>
<select
*ngSwitchCase="'messenger'"
[id]="configEntry.id+'_'+i"
[name]="configEntry.id+'_'+i"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]"
class="form-select">
<option *ngFor="let msg of jobsService.availableMessengers | async" [ngValue]="msg">{{msg}}
</option>
</select>
<ng-container *ngSwitchCase="'MediaPickDTO-array'">
<ng-container *ngFor="let mp of AsMediaPickDTOArray(schedule.config[configEntry.id]); let j=index">

View File

@ -108,6 +108,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
ngOnInit(): void {
this.jobsService.subscribeToProgress();
this.jobsService.getAvailableJobs().catch(console.error);
this.jobsService.getAvailableMessengers().catch(console.error);
}
ngOnDestroy(): void {
@ -128,7 +129,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
schedule.config = schedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (schedule.config[ct.id] = ct.defaultValue)
(ct) => (schedule.config[ct.id] = ct.defaultValue as never)
);
}
}
@ -216,7 +217,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
this.newSchedule.config = this.newSchedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (this.newSchedule.config[ct.id] = ct.defaultValue)
(ct) => (this.newSchedule.config[ct.id] = ct.defaultValue as never)
);
}
this.jobModalQL.first.show();

View File

@ -2,7 +2,7 @@ import {Config} from '../../src/common/config/private/Config';
import * as path from 'path';
import * as fs from 'fs';
import {SQLConnection} from '../../src/backend/model/database/SQLConnection';
import {DatabaseType, LogLevel} from '../../src/common/config/private/PrivateConfig';
import {DatabaseType} from '../../src/common/config/private/PrivateConfig';
import {ProjectPath} from '../../src/backend/ProjectPath';
import {DirectoryBaseDTO, ParentDirectoryDTO, SubDirectoryDTO} from '../../src/common/entities/DirectoryDTO';
import {ObjectManagers} from '../../src/backend/model/ObjectManagers';
@ -111,7 +111,7 @@ export class DBTestHelper {
}
public static async persistTestDir(directory: DirectoryBaseDTO): Promise<ParentDirectoryDTO> {
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
const connection = await SQLConnection.getConnection();
ObjectManagers.getInstance().IndexingManager.indexDirectory = () => Promise.resolve(null);
@ -122,20 +122,20 @@ export class DBTestHelper {
// await im.saveToDB(subDir2);
if (ObjectManagers.getInstance().IndexingManager &&
ObjectManagers.getInstance().IndexingManager.IsSavingInProgress) {
ObjectManagers.getInstance().IndexingManager.IsSavingInProgress) {
await ObjectManagers.getInstance().IndexingManager.SavingReady;
}
const gm = new GalleryManagerTest();
const dir = await gm.getParentDirFromId(connection,
(await gm.getDirIdAndTime(connection, directory.name, path.join(directory.path, path.sep))).id);
(await gm.getDirIdAndTime(connection, directory.name, path.join(directory.path, path.sep))).id);
const populateDir = async (d: DirectoryBaseDTO) => {
for (let i = 0; i < d.directories.length; i++) {
d.directories[i] = await gm.getParentDirFromId(connection,
(await gm.getDirIdAndTime(connection, d.directories[i].name,
path.join(DiskManager.pathFromParent(d), path.sep))).id);
(await gm.getDirIdAndTime(connection, d.directories[i].name,
path.join(DiskManager.pathFromParent(d), path.sep))).id);
await populateDir(d.directories[i]);
}
};
@ -147,6 +147,7 @@ export class DBTestHelper {
public async initDB(): Promise<void> {
await Config.load();
Config.Extensions.enabled = false; // make all tests clean
if (this.dbType === DatabaseType.sqlite) {
await this.initSQLite();
} else if (this.dbType === DatabaseType.mysql) {
@ -197,7 +198,7 @@ export class DBTestHelper {
const conn = await SQLConnection.getConnection();
await conn.query('CREATE DATABASE IF NOT EXISTS ' + conn.options.database);
await SQLConnection.close();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
}
private async clearUpMysql(): Promise<void> {
@ -218,7 +219,7 @@ export class DBTestHelper {
private async resetSQLite(): Promise<void> {
Logger.debug(LOG_TAG, 'resetting sqlite');
await this.clearUpSQLite();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
}
private async clearUpSQLite(): Promise<void> {

View File

@ -48,7 +48,7 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => {
afterEach(tearDown);
it('should load gallery', async () => {
const result = await (chai.request(server.App) as SuperAgentStatic)
const result = await (chai.request(server.Server) as SuperAgentStatic)
.get(Config.Server.apiPath + '/gallery/content/');
(result.should as any).have.status(200);
@ -59,10 +59,10 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => {
it('should load gallery twice (to force loading form db)', async () => {
Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low;
const _ = await (chai.request(server.App) as SuperAgentStatic)
const _ = await (chai.request(server.Server) as SuperAgentStatic)
.get(Config.Server.apiPath + '/gallery/content/orientation');
const result = await (chai.request(server.App) as SuperAgentStatic)
const result = await (chai.request(server.Server) as SuperAgentStatic)
.get(Config.Server.apiPath + '/gallery/content/orientation');
(result.should as any).have.status(200);
@ -80,7 +80,7 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => {
afterEach(tearDown);
it('should get video without transcoding', async () => {
const result = await (chai.request(server.App) as SuperAgentStatic)
const result = await (chai.request(server.Server) as SuperAgentStatic)
.get(Config.Server.apiPath + '/gallery/content/video.mp4/bestFit');
(result.should as any).have.status(200);

View File

@ -41,7 +41,7 @@ describe('PublicRouter', () => {
server = new Server();
await server.onStarted.wait();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser));
await SQLConnection.close();
};
@ -71,7 +71,7 @@ describe('PublicRouter', () => {
afterEach(tearDown);
const fistLoad = async (srv: Server, sharingKey: string): Promise<any> => {
return (chai.request(srv.App) as SuperAgentStatic)
return (chai.request(srv.Server) as SuperAgentStatic)
.get('/share/' + sharingKey);
};

View File

@ -42,7 +42,7 @@ describe('SharingRouter', () => {
server = new Server();
await server.onStarted.wait();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser));
await SQLConnection.close();
};
@ -62,7 +62,7 @@ describe('SharingRouter', () => {
};
const shareLogin = async (srv: Server, sharingKey: string, password?: string): Promise<any> => {
return (chai.request(srv.App) as SuperAgentStatic)
return (chai.request(srv.Server) as SuperAgentStatic)
.post(Config.Server.apiPath + '/share/login?' + QueryParams.gallery.sharingKey_query + '=' + sharingKey)
.send({password});

View File

@ -42,7 +42,7 @@ describe('UserRouter', () => {
server = new Server();
await server.onStarted.wait();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser));
await SQLConnection.close();
};
@ -62,7 +62,7 @@ describe('UserRouter', () => {
};
const login = async (srv: Server): Promise<any> => {
const result = await (chai.request(srv.App) as SuperAgentStatic)
const result = await (chai.request(srv.Server) as SuperAgentStatic)
.post(Config.Server.apiPath + '/user/login')
.send({
loginCredential: {
@ -87,7 +87,7 @@ describe('UserRouter', () => {
});
it('it skip login', async () => {
Config.Users.authenticationRequired = false;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.post(Config.Server.apiPath + '/user/login');
result.res.should.have.status(404);
@ -105,7 +105,7 @@ describe('UserRouter', () => {
const loginRes = await login(server);
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me')
.set('Cookie', loginRes.res.headers['set-cookie'])
.set('CSRF-Token', loginRes.body.result.csrfToken);
@ -116,7 +116,7 @@ describe('UserRouter', () => {
it('it should not authenticate', async () => {
Config.Users.authenticationRequired = true;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me');
result.res.should.have.status(401);
@ -133,7 +133,7 @@ describe('UserRouter', () => {
const loginRes = await login(server);
const q: Record<string, string> = {};
q[QueryParams.gallery.sharingKey_query] = sharingKey;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharingKey)
.set('Cookie', loginRes.res.headers['set-cookie'])
.set('CSRF-Token', loginRes.body.result.csrfToken);
@ -152,7 +152,7 @@ describe('UserRouter', () => {
const q: Record<string, string> = {};
q[QueryParams.gallery.sharingKey_query] = sharing.sharingKey;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharing.sharingKey);
checkUserResult(result, RouteTestingHelper.getExpectedSharingUser(sharing));
@ -167,7 +167,7 @@ describe('UserRouter', () => {
const q: Record<string, string> = {};
q[QueryParams.gallery.sharingKey_query] = sharing.sharingKey;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharing.sharingKey);
result.should.have.status(401);
@ -179,7 +179,7 @@ describe('UserRouter', () => {
it('it should authenticate as guest', async () => {
Config.Users.authenticationRequired = false;
const result = await chai.request(server.App)
const result = await chai.request(server.Server)
.get(Config.Server.apiPath + '/user/me');
const expectedGuestUser = {

View File

@ -7,6 +7,7 @@ import {ProjectPath} from '../../../../../src/backend/ProjectPath';
import {TAGS} from '../../../../../src/common/config/public/ClientConfig';
import {ObjectManagers} from '../../../../../src/backend/model/ObjectManagers';
import {UserRoles} from '../../../../../src/common/entities/UserDTO';
import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper';
process.env.NODE_ENV = 'test';
const chai: any = require('chai');
@ -34,10 +35,10 @@ describe('SettingsRouter', () => {
it('it should GET the settings', async () => {
Config.Users.authenticationRequired = false;
Config.Users.unAuthenticatedUserRole = UserRoles.Admin;
const originalSettings = await Config.original();
const originalSettings = await ExtensionConfigWrapper.original();
const srv = new Server();
await srv.onStarted.wait();
const result = await chai.request(srv.App)
const result = await chai.request(srv.Server)
.get(Config.Server.apiPath + '/settings');
result.res.should.have.status(200);

View File

@ -6,9 +6,10 @@ import {SettingsMWs} from '../../../../../src/backend/middlewares/admin/Settings
import {ServerUserConfig} from '../../../../../src/common/config/private/PrivateConfig';
import {Config} from '../../../../../src/common/config/private/Config';
import {UserRoles} from '../../../../../src/common/entities/UserDTO';
import {ConfigClassBuilder} from '../../../../../node_modules/typeconfig/node';
import * as fs from 'fs';
import * as path from 'path';
import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper';
import {ConfigClassBuilder} from 'typeconfig/node';
declare const describe: any;
@ -21,7 +22,7 @@ describe('Settings middleware', () => {
beforeEach(async () => {
await ObjectManagers.reset();
await fs.promises.rm(tempDir, {recursive: true, force: true});
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
});
it('should save empty enforced users settings', (done: (err?: any) => void) => {
@ -74,7 +75,7 @@ describe('Settings middleware', () => {
expect(Config.Users.enforcedUsers.length).to.be.equal(1);
expect(Config.Users.enforcedUsers[0].name).to.be.equal('Apple');
expect(Config.Users.enforcedUsers.length).to.be.equal(1);
Config.original().then((cfg) => {
ExtensionConfigWrapper.original().then((cfg) => {
try {
expect(cfg.Users.enforcedUsers.length).to.be.equal(1);
expect(cfg.Users.enforcedUsers[0].name).to.be.equal('Apple');

View File

@ -33,7 +33,7 @@ describe('AlbumManager', (sqlHelper: DBTestHelper) => {
const setUpSqlDB = async () => {
await sqlHelper.initDB();
await sqlHelper.setUpTestGallery();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
};

View File

@ -122,7 +122,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
const setUpSqlDB = async () => {
await sqlHelper.initDB();
await setUpTestGallery();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
};
@ -212,21 +212,21 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
it('should get cover for saved search', async () => {
const pm = new CoverManager();
Config.AlbumCover.SearchQuery = null;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
} as TextSearch
}))).to.deep.equalInAnyOrder(previewifyMedia(p4));
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Boba'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
} as TextSearch
}))).to.deep.equalInAnyOrder(previewifyMedia(p));
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
@ -234,7 +234,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
}))).to.deep.equalInAnyOrder(previewifyMedia(p2));
// Having a preview search query that does not return valid result
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'wont find it'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'Derem'
@ -242,7 +242,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
}))).to.deep.equalInAnyOrder(previewifyMedia(p2));
// having a saved search that does not have any image
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'wont find it'

View File

@ -146,7 +146,7 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
const setUpSqlDB = async () => {
await sqlHelper.initDB();
await setUpTestGallery();
await ObjectManagers.InitSQLManagers();
await ObjectManagers.getInstance().init();
};

View File

@ -15,6 +15,7 @@ describe('DiskMangerWorker', () => {
Config.Database.type = DatabaseType.sqlite;
Config.Faces.enabled = true;
Config.Faces.keywordsToPersons = true;
Config.Extensions.enabled = false;
});
@ -24,6 +25,7 @@ describe('DiskMangerWorker', () => {
const dir = await DiskManager.scanDirectory('/');
// should match the number of media (photo/video) files in the assets folder
expect(dir.media.length).to.be.equals(10);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const expected = require(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.json'));
const i = dir.media.findIndex(m => m.name === 'test image öüóőúéáű-.,.jpg');
expect(Utils.clone(dir.media[i].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg');

View File

@ -18,6 +18,7 @@ describe('MetadataLoader', () => {
Config.Database.type = DatabaseType.sqlite;
Config.Faces.enabled = true;
Config.Faces.keywordsToPersons = true;
Config.Extensions.enabled = false;
});
@ -66,7 +67,7 @@ describe('MetadataLoader', () => {
});
it('jpg 2', async () => {
const data = await MetadataLoader.loadPhotoMetadata(
path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.jpg'));
path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.jpg'));
const expected = require(path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.json'));
expect(Utils.clone(data)).to.be.deep.equal(expected);
});