diff --git a/src/backend/middlewares/user/UserMWs.ts b/src/backend/middlewares/user/UserMWs.ts index 1f802a59..5d2b2aaa 100644 --- a/src/backend/middlewares/user/UserMWs.ts +++ b/src/backend/middlewares/user/UserMWs.ts @@ -6,23 +6,23 @@ import {Config} from '../../../common/config/private/Config'; export class UserMWs { public static async createUser( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (Config.Users.authenticationRequired === false) { return next(new ErrorDTO(ErrorCodes.USER_MANAGEMENT_DISABLED)); } if ( - typeof req.body === 'undefined' || - typeof req.body.newUser === 'undefined' + typeof req.body === 'undefined' || + typeof req.body.newUser === 'undefined' ) { return next(); } try { await ObjectManagers.getInstance().UserManager.createUser( - req.body.newUser + req.body.newUser ); return next(); } catch (err) { @@ -31,24 +31,28 @@ export class UserMWs { } public static async deleteUser( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (Config.Users.authenticationRequired === false) { return next(new ErrorDTO(ErrorCodes.USER_MANAGEMENT_DISABLED)); } if ( - typeof req.params === 'undefined' || - typeof req.params.id === 'undefined' + typeof req.params === 'undefined' || + typeof req.params.id === 'undefined' ) { return next(); } try { - await ObjectManagers.getInstance().UserManager.deleteUser( - parseInt(req.params.id, 10) + const deleted = await ObjectManagers.getInstance().UserManager.deleteUser( + parseInt(req.params.id, 10) ); + // If current session user was deleted, clear the session context + if (req.session?.context?.user?.id === deleted.id) { + delete req.session.context; + } return next(); } catch (err) { return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, null, err)); @@ -56,37 +60,87 @@ export class UserMWs { } public static async changeRole( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (Config.Users.authenticationRequired === false) { return next(new ErrorDTO(ErrorCodes.USER_MANAGEMENT_DISABLED)); } if ( - typeof req.params === 'undefined' || - typeof req.params.id === 'undefined' || - typeof req.body === 'undefined' || - typeof req.body.newRole === 'undefined' + typeof req.params === 'undefined' || + typeof req.params.id === 'undefined' || + typeof req.body === 'undefined' || + typeof req.body.newRole === 'undefined' ) { return next(); } try { - await ObjectManagers.getInstance().UserManager.changeRole( - parseInt(req.params.id, 10), - req.body.newRole + const updatedUser = await ObjectManagers.getInstance().UserManager.changeRole( + parseInt(req.params.id, 10), + req.body.newRole ); + // If the current session user was changed, recreate the session context + if (req.session?.context?.user?.id === updatedUser.id) { + const newUser: any = Utils.clone(updatedUser); + // Do not keep password in session + if (newUser && typeof newUser.password !== 'undefined') { + newUser.password = ''; + } + // Preserve usedSharingKey if any + if (req.session.context.user && (req.session.context.user as any).usedSharingKey) { + newUser.usedSharingKey = (req.session.context.user as any).usedSharingKey; + } + req.session.context = await ObjectManagers.getInstance().buildContext(newUser); + } return next(); } catch (err) { return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, null, err)); } } + public static async updateSettings( + req: Request, + res: Response, + next: NextFunction + ): Promise { + if (Config.Users.authenticationRequired === false) { + return next(new ErrorDTO(ErrorCodes.USER_MANAGEMENT_DISABLED)); + } + if ( + typeof req.params === 'undefined' || + typeof req.params.id === 'undefined' || + typeof req.body === 'undefined' || + typeof req.body.settings === 'undefined' + ) { + return next(); + } + + try { + const updatedUser = await ObjectManagers.getInstance().UserManager.updateSettings( + parseInt(req.params.id, 10), + req.body.settings + ); + // If the current session user was changed, recreate the session context + if (req.session?.context?.user?.id === updatedUser.id) { + const user = Utils.clone( + await ObjectManagers.getInstance().UserManager.findOne({ + id: updatedUser.id + }) + ); + req.session.context = await ObjectManagers.getInstance().buildContext(user); + } + return next(); + } catch (err) { + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Can\'t update user settings', err)); + } + } + public static async listUsers( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (Config.Users.authenticationRequired === false) { return next(new ErrorDTO(ErrorCodes.USER_MANAGEMENT_DISABLED)); diff --git a/src/backend/model/ObjectManagers.ts b/src/backend/model/ObjectManagers.ts index b3715d89..a9243ead 100644 --- a/src/backend/model/ObjectManagers.ts +++ b/src/backend/model/ObjectManagers.ts @@ -17,10 +17,10 @@ import {SharingManager} from './database/SharingManager'; import {IObjectManager} from './database/IObjectManager'; import {ExtensionManager} from './extension/ExtensionManager'; import {ContextUser, SessionContext} from './SessionContext'; -import {UserEntity} from './database/enitites/UserEntity'; -import {ANDSearchQuery, SearchQueryDTO, SearchQueryDTOUtils, SearchQueryTypes} from '../../common/entities/SearchQueryDTO'; +import {ANDSearchQuery, SearchQueryDTO, SearchQueryTypes} from '../../common/entities/SearchQueryDTO'; import {Config} from '../../common/config/private/Config'; import {SharingEntity} from './database/enitites/SharingEntity'; +import {SearchQueryUtils} from '../../common/SearchQueryUtils'; const LOG_TAG = '[ObjectManagers]'; @@ -280,15 +280,15 @@ export class ObjectManagers { let blockQuery = user.overrideAllowBlockList ? user.blockQuery : Config.Users.blockQuery; const allowQuery = user.overrideAllowBlockList ? user.allowQuery : Config.Users.allowQuery; - if (!SearchQueryDTOUtils.isValidQuery(allowQuery) && !SearchQueryDTOUtils.isValidQuery(blockQuery)) { + if (!SearchQueryUtils.isQueryEmpty(allowQuery) && !SearchQueryUtils.isQueryEmpty(blockQuery)) { return null; } - if (SearchQueryDTOUtils.isValidQuery(blockQuery)) { - blockQuery = SearchQueryDTOUtils.negate(blockQuery); + if (SearchQueryUtils.isQueryEmpty(blockQuery)) { + blockQuery = SearchQueryUtils.negate(blockQuery); } - let query = SearchQueryDTOUtils.isValidQuery(allowQuery) ? allowQuery : blockQuery; - if (SearchQueryDTOUtils.isValidQuery(allowQuery) && SearchQueryDTOUtils.isValidQuery(blockQuery)) { + let query = SearchQueryUtils.isQueryEmpty(allowQuery) ? allowQuery : blockQuery; + if (SearchQueryUtils.isQueryEmpty(allowQuery) && SearchQueryUtils.isQueryEmpty(blockQuery)) { query = { type: SearchQueryTypes.AND, list: [ @@ -317,7 +317,7 @@ export class ObjectManagers { } public createProjectionKey(q: SearchQueryDTO) { - const canonical = SearchQueryDTOUtils.stringifyForComparison(q); + const canonical = SearchQueryUtils.stringifyForComparison(q); return 'pr:' + crypto.createHash('md5').update(canonical).digest('hex'); } @@ -330,6 +330,9 @@ export class ObjectManagers { // Build the Brackets-based query context.projectionQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery); context.user.projectionKey = ObjectManagers.getInstance().createProjectionKey(finalQuery); + Logger.silly(LOG_TAG, 'Projection query: ' + JSON.stringify(context.projectionQuery)); + }else{ + context.user.projectionKey = 'pr:' + crypto.createHash('md5').update('No Key').digest('hex'); } return context; } diff --git a/src/backend/model/database/SharingManager.ts b/src/backend/model/database/SharingManager.ts index 78be30c6..7cfed712 100644 --- a/src/backend/model/database/SharingManager.ts +++ b/src/backend/model/database/SharingManager.ts @@ -5,7 +5,8 @@ import {Config} from '../../../common/config/private/Config'; import {PasswordHelper} from '../PasswordHelper'; import {DeleteResult, SelectQueryBuilder} from 'typeorm'; import {UserDTO} from '../../../common/entities/UserDTO'; -import {SearchQueryDTO, SearchQueryDTOUtils} from '../../../common/entities/SearchQueryDTO'; +import {SearchQueryDTO} from '../../../common/entities/SearchQueryDTO'; +import {SearchQueryUtils} from '../../../common/SearchQueryUtils'; export class SharingManager { private static async removeExpiredLink(): Promise { @@ -44,7 +45,7 @@ export class SharingManager { .getRepository(SharingEntity) .createQueryBuilder('share') .leftJoinAndSelect('share.creator', 'creator') - .where('share.searchQuery = :query', {query: SearchQueryDTOUtils.stringifyForComparison(query)}); + .where('share.searchQuery = :query', {query: SearchQueryUtils.stringifyForComparison(query)}); if (user) { q.where('share.creator = :user', {user: user.id}); } @@ -67,8 +68,9 @@ export class SharingManager { if (sharing.password) { sharing.password = PasswordHelper.cryptPassword(sharing.password); } - if ((sharing as any).searchQuery) { - (sharing as any).searchQuery = SearchQueryDTOUtils.sortQuery((sharing as any).searchQuery as SearchQueryDTO); + if (sharing.searchQuery) { + SearchQueryUtils.validateSearchQuery(sharing.searchQuery) + sharing.searchQuery = SearchQueryUtils.sortQuery(sharing.searchQuery); } return connection.getRepository(SharingEntity).save(sharing); } @@ -96,7 +98,7 @@ export class SharingManager { sharing.password = PasswordHelper.cryptPassword(inSharing.password); } // allow updating searchQuery and canonicalize it - sharing.searchQuery = SearchQueryDTOUtils.sortQuery(inSharing.searchQuery); + sharing.searchQuery = SearchQueryUtils.sortQuery(inSharing.searchQuery); sharing.expires = inSharing.expires; return connection.getRepository(SharingEntity).save(sharing); diff --git a/src/backend/model/database/UserManager.ts b/src/backend/model/database/UserManager.ts index 10ee9fbe..5122c102 100644 --- a/src/backend/model/database/UserManager.ts +++ b/src/backend/model/database/UserManager.ts @@ -3,6 +3,8 @@ import {UserEntity} from './enitites/UserEntity'; import {SQLConnection} from './SQLConnection'; import {PasswordHelper} from '../PasswordHelper'; import {FindOptionsWhere} from 'typeorm'; +import {UserSettingsDTO} from '../../../common/entities/UserSettingsDTO'; +import {SearchQueryUtils} from '../../../common/SearchQueryUtils'; export class UserManager { @@ -26,6 +28,13 @@ export class UserManager { public async createUser(user: UserDTO): Promise { const connection = await SQLConnection.getConnection(); + // Validate search queries if provided + if (user.allowQuery) { + SearchQueryUtils.validateSearchQuery(user.allowQuery, 'User allowQuery'); + } + if (user.blockQuery) { + SearchQueryUtils.validateSearchQuery(user.blockQuery, 'User blockQuery'); + } user.password = PasswordHelper.cryptPassword(user.password); return connection.getRepository(UserEntity).save(user); } @@ -44,4 +53,38 @@ export class UserManager { return userRepository.save(user); } + public async updateSettings(id: number, settings: UserSettingsDTO): Promise { + const connection = await SQLConnection.getConnection(); + const userRepository = connection.getRepository(UserEntity); + const user = await userRepository.findOneBy({id}); + + if (!user) { + throw new Error('User not found'); + } + + if (typeof settings.overrideAllowBlockList !== 'undefined') { + user.overrideAllowBlockList = settings.overrideAllowBlockList; + } + + if (typeof settings.allowQuery !== 'undefined') { + if (settings.allowQuery) { + SearchQueryUtils.validateSearchQuery(settings.allowQuery, 'User allowQuery'); + } + user.allowQuery = settings.allowQuery ?? null; + } + + if (typeof settings.blockQuery !== 'undefined') { + if (settings.blockQuery) { + SearchQueryUtils.validateSearchQuery(settings.blockQuery, 'User blockQuery'); + } + user.blockQuery = settings.blockQuery ?? null; + } + + if (settings.newPassword && settings.newPassword.length > 0) { + user.password = PasswordHelper.cryptPassword(settings.newPassword); + } + + return userRepository.save(user); + } + } diff --git a/src/backend/model/diagnostics/ConfigDiagnostics.ts b/src/backend/model/diagnostics/ConfigDiagnostics.ts index 6b15a89e..50a88c1b 100644 --- a/src/backend/model/diagnostics/ConfigDiagnostics.ts +++ b/src/backend/model/diagnostics/ConfigDiagnostics.ts @@ -25,9 +25,9 @@ import { ServerPhotoConfig, ServerVideoConfig, } from '../../../common/config/private/PrivateConfig'; -import {SearchQueryParser} from '../../../common/SearchQueryParser'; import {SearchQueryTypes, TextSearch,} from '../../../common/entities/SearchQueryDTO'; import {Utils} from '../../../common/Utils'; +import {SearchQueryUtils} from '../../../common/SearchQueryUtils'; import {JobRepository} from '../jobs/JobRepository'; import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; import {Config} from '../../../common/config/private/Config'; @@ -285,16 +285,7 @@ export class ConfigDiagnostics { static async testAlbumCoverConfig(settings: ServerAlbumCoverConfig): Promise { Logger.debug(LOG_TAG, 'Testing cover config'); - const sp = new SearchQueryParser(); - if ( - !Utils.equalsFilter( - sp.parse(sp.stringify(settings.SearchQuery)), - settings.SearchQuery - ) - ) { - throw new Error('SearchQuery is not valid. Expected: ' + JSON.stringify(sp.parse(sp.stringify(settings.SearchQuery))) + - ' Got: ' + JSON.stringify(settings.SearchQuery)); - } + SearchQueryUtils.validateSearchQuery(settings.SearchQuery, 'SearchQuery'); } /** diff --git a/src/backend/routes/UserRouter.ts b/src/backend/routes/UserRouter.ts index 9c186e2f..5fc25413 100644 --- a/src/backend/routes/UserRouter.ts +++ b/src/backend/routes/UserRouter.ts @@ -17,6 +17,7 @@ export class UserRouter { this.addDeleteUser(app); this.addListUsers(app); this.addChangeRole(app); + this.addChangeSettings(app); } private static addLogin(app: Express): void { @@ -92,4 +93,15 @@ export class UserRouter { RenderingMWs.renderOK ); } + + private static addChangeSettings(app: Express): void { + app.post( + Config.Server.apiPath + '/user/:id/settings', + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + UserMWs.updateSettings, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderOK + ); + } } diff --git a/src/common/SearchQueryUtils.ts b/src/common/SearchQueryUtils.ts new file mode 100644 index 00000000..55af6c16 --- /dev/null +++ b/src/common/SearchQueryUtils.ts @@ -0,0 +1,129 @@ +import { + NegatableSearchQuery, + OrientationSearch, + SearchListQuery, + SearchQueryDTO, + SearchQueryTypes, + SomeOfSearchQuery, + TextSearch +} from './entities/SearchQueryDTO'; +import {SearchQueryParser} from './SearchQueryParser'; +import {Utils} from './Utils'; + +export const SearchQueryUtils = { + negate: (query: SearchQueryDTO): SearchQueryDTO => { + switch (query.type) { + case SearchQueryTypes.AND: + query.type = SearchQueryTypes.OR; + (query as SearchListQuery).list = (query as SearchListQuery).list.map( + (q) => SearchQueryUtils.negate(q) + ); + return query; + case SearchQueryTypes.OR: + query.type = SearchQueryTypes.AND; + (query as SearchListQuery).list = (query as SearchListQuery).list.map( + (q) => SearchQueryUtils.negate(q) + ); + return query; + case SearchQueryTypes.orientation: + (query as OrientationSearch).landscape = !(query as OrientationSearch).landscape; + return query; + case SearchQueryTypes.from_date: + case SearchQueryTypes.to_date: + case SearchQueryTypes.min_rating: + case SearchQueryTypes.max_rating: + case SearchQueryTypes.min_resolution: + case SearchQueryTypes.max_resolution: + case SearchQueryTypes.distance: + case SearchQueryTypes.any_text: + case SearchQueryTypes.person: + case SearchQueryTypes.position: + case SearchQueryTypes.keyword: + case SearchQueryTypes.caption: + case SearchQueryTypes.file_name: + case SearchQueryTypes.directory: + (query as NegatableSearchQuery).negate = !(query as NegatableSearchQuery).negate; + return query; + case SearchQueryTypes.SOME_OF: + throw new Error('Some of not supported'); + default: + throw new Error('Unknown type' + (query).type); + } + }, + sortQuery(queryIN: SearchQueryDTO): SearchQueryDTO { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const canonicalize = (value: any): any => { + if (Array.isArray(value)) { + return value.map((v) => canonicalize(v)); + } + if (value && typeof value === 'object') { + const out: Record = {}; + const keys = Object.keys(value).sort(); + for (const k of keys) { + const v = canonicalize(value[k]); + if (v !== undefined) { + out[k] = v; + } + } + return out; + } + return value; + }; + + if (!queryIN || (queryIN).type === undefined) { + return queryIN; + } + if ( + queryIN.type === SearchQueryTypes.AND || + queryIN.type === SearchQueryTypes.OR || + queryIN.type === SearchQueryTypes.SOME_OF + ) { + const ql = queryIN as SearchListQuery; + const children = (ql.list || []).map((c) => SearchQueryUtils.sortQuery(c)); + const withKeys = children.map((c) => ({key: JSON.stringify(c), value: c})); + withKeys.sort((a, b) => a.key.localeCompare(b.key)); + if (queryIN.type === SearchQueryTypes.SOME_OF) { + const so = queryIN as SomeOfSearchQuery; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res: any = {type: queryIN.type}; + if (so.min !== undefined) { + res.min = so.min; + } + res.list = withKeys.map((kv) => kv.value); + return canonicalize(res) as SearchQueryDTO; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res: any = {type: queryIN.type}; + res.list = withKeys.map((kv) => kv.value); + return canonicalize(res) as SearchQueryDTO; + } + } + return canonicalize(queryIN) as SearchQueryDTO; + }, + stringifyForComparison(queryIN: SearchQueryDTO): string { + return JSON.stringify(SearchQueryUtils.sortQuery(queryIN)); + }, + isQueryEmpty(query: SearchQueryDTO): boolean { + return !!(query && query.type !== undefined && !(query.type === SearchQueryTypes.any_text && !(query as TextSearch).text)); + }, + validateSearchQuery(query: SearchQueryDTO, what = 'SearchQuery'): void { + if (!query) { + return; + } + const sp = new SearchQueryParser(); + try { + const parsed = sp.parse(sp.stringify(query)); + if (!Utils.equalsFilter(parsed, query)) { + throw new Error( + `${what} is not valid. Expected: ${JSON.stringify(parsed)} Got: ${JSON.stringify(query)}` + ); + } + } catch (e) { + if (e && e.message && e.message.startsWith(what)) { + throw e; + } + throw new Error(`${what} is not valid. ${e?.message ?? e}`); + } + } +}; + diff --git a/src/common/entities/SearchQueryDTO.ts b/src/common/entities/SearchQueryDTO.ts index 77d34571..b39d98ee 100644 --- a/src/common/entities/SearchQueryDTO.ts +++ b/src/common/entities/SearchQueryDTO.ts @@ -71,134 +71,12 @@ export const MetadataSearchQueryTypes = [ .concat(RangeSearchQueryTypes) .concat(TextSearchQueryTypes); -export const rangedTypePairs: any = {}; -rangedTypePairs[SearchQueryTypes.from_date] = SearchQueryTypes.to_date; -rangedTypePairs[SearchQueryTypes.min_rating] = SearchQueryTypes.max_rating; -rangedTypePairs[SearchQueryTypes.min_resolution] = - SearchQueryTypes.max_resolution; -// add the other direction too -for (const key of Object.keys(rangedTypePairs)) { - rangedTypePairs[rangedTypePairs[key]] = key; -} export enum TextSearchQueryMatchTypes { exact_match = 1, like = 2, } -export const SearchQueryDTOUtils = { - getRangedQueryPair: (type: SearchQueryTypes): SearchQueryTypes => { - if (rangedTypePairs[type]) { - return rangedTypePairs[type]; - } - throw new Error('Unknown ranged type'); - }, - negate: (query: SearchQueryDTO): SearchQueryDTO => { - switch (query.type) { - case SearchQueryTypes.AND: - query.type = SearchQueryTypes.OR; - (query as SearchListQuery).list = (query as SearchListQuery).list.map( - (q) => SearchQueryDTOUtils.negate(q) - ); - return query; - case SearchQueryTypes.OR: - query.type = SearchQueryTypes.AND; - (query as SearchListQuery).list = (query as SearchListQuery).list.map( - (q) => SearchQueryDTOUtils.negate(q) - ); - return query; - - case SearchQueryTypes.orientation: - (query as OrientationSearch).landscape = !(query as OrientationSearch) - .landscape; - return query; - - case SearchQueryTypes.from_date: - case SearchQueryTypes.to_date: - case SearchQueryTypes.min_rating: - case SearchQueryTypes.max_rating: - case SearchQueryTypes.min_resolution: - case SearchQueryTypes.max_resolution: - case SearchQueryTypes.distance: - case SearchQueryTypes.any_text: - case SearchQueryTypes.person: - case SearchQueryTypes.position: - case SearchQueryTypes.keyword: - case SearchQueryTypes.caption: - case SearchQueryTypes.file_name: - case SearchQueryTypes.directory: - (query as NegatableSearchQuery).negate = !( - query as NegatableSearchQuery - ).negate; - return query; - - case SearchQueryTypes.SOME_OF: - throw new Error('Some of not supported'); - - default: - throw new Error('Unknown type' + query.type); - } - }, - isValidQuery(query: SearchQueryDTO): boolean { - return query && query.type !== undefined && !(query.type === SearchQueryTypes.any_text && !(query as TextSearch).text); - }, - // Returns a new SearchQueryDTO where list-type subqueries are recursively sorted - // into a canonical order for equality checks. This does not change semantics. - sortQuery(queryIN: SearchQueryDTO): SearchQueryDTO { - // Canonicalize object keys recursively to make JSON order independent - const canonicalize = (value: any): any => { - if (Array.isArray(value)) { - return value.map((v) => canonicalize(v)); - } - if (value && typeof value === 'object') { - const out: any = {}; - const keys = Object.keys(value).sort(); - for (const k of keys) { - const v = canonicalize((value as any)[k]); - if (v !== undefined) { - out[k] = v; - } - } - return out; - } - return value; - }; - - if (!queryIN || queryIN.type === undefined) { - return queryIN; - } - // Reorder list queries and canonicalize properties - if (queryIN.type === SearchQueryTypes.AND || - queryIN.type === SearchQueryTypes.OR || - queryIN.type === SearchQueryTypes.SOME_OF) { - const ql = queryIN as SearchListQuery; - const children = (ql.list || []).map((c) => SearchQueryDTOUtils.sortQuery(c)); - // Stable key using JSON.stringify of already-sorted, property-canonicalized children - const withKeys = children.map((c) => ({ key: JSON.stringify(c), value: c })); - withKeys.sort((a, b) => a.key.localeCompare(b.key)); - if (queryIN.type === SearchQueryTypes.SOME_OF) { - const so = queryIN as SomeOfSearchQuery; - const res: any = { type: queryIN.type }; - if (so.min !== undefined) { - res.min = so.min; - } - res.list = withKeys.map((kv) => kv.value); - return canonicalize(res) as SearchQueryDTO; - } else { - const res: any = { type: queryIN.type }; - res.list = withKeys.map((kv) => kv.value); - return canonicalize(res) as SearchQueryDTO; - } - } - // For non-list queries return with sorted properties to avoid order differences - return canonicalize(queryIN) as SearchQueryDTO; - }, - // Stringify a query in canonical form for comparing or persistence - stringifyForComparison(queryIN: SearchQueryDTO): string { - return JSON.stringify(SearchQueryDTOUtils.sortQuery(queryIN)); - } -}; - export interface SearchQueryDTO { type: SearchQueryTypes; } @@ -224,7 +102,7 @@ export interface ORSearchQuery extends SearchQueryDTO, SearchListQuery { export interface SomeOfSearchQuery extends SearchQueryDTO, SearchListQuery { type: SearchQueryTypes.SOME_OF; list: NegatableSearchQuery[]; - min?: number; // at least this amount of items + min?: number; // at least this number of items } export interface TextSearch extends NegatableSearchQuery { diff --git a/src/common/entities/UserDTO.ts b/src/common/entities/UserDTO.ts index c9bbc62a..df44f809 100644 --- a/src/common/entities/UserDTO.ts +++ b/src/common/entities/UserDTO.ts @@ -1,3 +1,5 @@ +import {SearchQueryDTO} from './SearchQueryDTO'; + export enum UserRoles { LimitedGuest = 1, // sharing user Guest = 2, // user when authentication is disabled @@ -13,4 +15,8 @@ export interface UserDTO { role: UserRoles; usedSharingKey?: string; projectionKey?: string; // allow- and blocklist projection hash. if null, no projection + // Optional per-user query overrides + overrideAllowBlockList?: boolean; + allowQuery?: SearchQueryDTO | null; + blockQuery?: SearchQueryDTO | null; } diff --git a/src/common/entities/UserSettingsDTO.ts b/src/common/entities/UserSettingsDTO.ts new file mode 100644 index 00000000..139cedb5 --- /dev/null +++ b/src/common/entities/UserSettingsDTO.ts @@ -0,0 +1,8 @@ +import {SearchQueryDTO} from './SearchQueryDTO'; + +export interface UserSettingsDTO { + newPassword?: string; + overrideAllowBlockList?: boolean; + allowQuery?: SearchQueryDTO | null; + blockQuery?: SearchQueryDTO | null; +} diff --git a/src/frontend/app/ui/settings/users/users.component.html b/src/frontend/app/ui/settings/users/users.component.html index 91b563c7..02502854 100644 --- a/src/frontend/app/ui/settings/users/users.component.html +++ b/src/frontend/app/ui/settings/users/users.component.html @@ -8,7 +8,7 @@ - + @@ -20,23 +20,21 @@ - - - + @@ -66,7 +64,7 @@ @@ -81,3 +79,79 @@ + + + diff --git a/src/frontend/app/ui/settings/users/users.component.ts b/src/frontend/app/ui/settings/users/users.component.ts index 863d46ed..025a6d32 100644 --- a/src/frontend/app/ui/settings/users/users.component.ts +++ b/src/frontend/app/ui/settings/users/users.component.ts @@ -8,49 +8,56 @@ import {Utils} from '../../../../../common/Utils'; import {ErrorCodes, ErrorDTO} from '../../../../../common/entities/Error'; import {UsersSettingsService} from './users.service'; import {SettingsService} from '../settings.service'; -import { NgIf, NgFor, NgClass } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { NgIconComponent } from '@ng-icons/core'; -import { StringifyRole } from '../../../pipes/StringifyRolePipe'; +import {NgClass, NgFor, NgIf} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {NgIconComponent} from '@ng-icons/core'; +import {StringifyRole} from '../../../pipes/StringifyRolePipe'; +import {UserSettingsDTO} from '../../../../../common/entities/UserSettingsDTO'; +import {SearchQueryDTO, SearchQueryTypes, TextSearch} from '../../../../../common/entities/SearchQueryDTO'; +import {GallerySearchFieldComponent} from '../../gallery/search/search-field/search-field.gallery.component'; @Component({ - selector: 'app-settings-users', - templateUrl: './users.component.html', - styleUrls: ['./users.component.css'], - imports: [NgIf, NgFor, FormsModule, NgClass, NgIconComponent, ModalDirective, StringifyRole] + selector: 'app-settings-users', + templateUrl: './users.component.html', + styleUrls: ['./users.component.css'], + imports: [NgIf, NgFor, FormsModule, NgClass, NgIconComponent, ModalDirective, StringifyRole, GallerySearchFieldComponent] }) export class UsersComponent implements OnInit { @ViewChild('userModal', {static: false}) public childModal: ModalDirective; + @ViewChild('editUserModal', {static: false}) public editModal: ModalDirective; public newUser = {} as UserDTO; public userRoles: { key: number; value: string }[] = []; public users: UserDTO[] = []; public error: string = null; public inProgress = false; - Changed = false; + public editUser: UserDTO = null; + public editSettings: UserSettingsDTO = {}; + public newPassword = ''; + public editOriginalUser: UserDTO = null; constructor( - private authService: AuthenticationService, - private navigation: NavigationService, - private userSettings: UsersSettingsService, - private settingsService: SettingsService, - private notification: NotificationService + private authService: AuthenticationService, + private navigation: NavigationService, + private userSettings: UsersSettingsService, + private settingsService: SettingsService, + private notification: NotificationService ) { } ngOnInit(): void { if ( - !this.authService.isAuthenticated() || - this.authService.user.value.role < UserRoles.Admin + !this.authService.isAuthenticated() || + this.authService.user.value.role < UserRoles.Admin ) { this.navigation.toLogin(); return; } this.userRoles = Utils.enumToArray(UserRoles) - .filter((r) => r.key !== UserRoles.LimitedGuest) - .filter((r) => r.key <= this.authService.user.value.role) - .sort((a, b) => a.key - b.key); + .filter((r) => r.key !== UserRoles.LimitedGuest) + .filter((r) => r.key <= this.authService.user.value.role) + .sort((a, b) => a.key - b.key); this.getUsersList(); } @@ -61,6 +68,24 @@ export class UsersComponent implements OnInit { return false; } + return currentUser.role >= user.role; + } + + canDeleteUser(user: UserDTO): boolean { + const currentUser = this.authService.user.value; + if (!currentUser) { + return false; + } + + return currentUser.name !== user.name && currentUser.role >= user.role; + } + + canModifyRole(user: UserDTO): boolean { + const currentUser = this.authService.user.value; + if (!currentUser) { + return false; + } + return currentUser.name !== user.name && currentUser.role >= user.role; } @@ -82,8 +107,8 @@ export class UsersComponent implements OnInit { } catch (e) { const err: ErrorDTO = e; this.notification.error( - err.message + ', ' + err.details, - $localize`User creation error!` + err.message + ', ' + err.details, + $localize`User creation error!` ); } } @@ -100,6 +125,69 @@ export class UsersComponent implements OnInit { this.childModal.hide(); } + async openEditUser(user: UserDTO): Promise { + await this.getUsersList(); + const fresh = this.users.find(u => u.id === user.id) || user; + this.editUser = {...fresh}; + this.editOriginalUser = Utils.clone(this.editUser); + const defaultQuery: SearchQueryDTO = {type: SearchQueryTypes.any_text, text: ''} as TextSearch; + this.editSettings = { + overrideAllowBlockList: fresh.overrideAllowBlockList ?? false, + allowQuery: fresh.allowQuery ?? defaultQuery, + blockQuery: fresh.blockQuery ?? defaultQuery + } as UserSettingsDTO; + this.newPassword = ''; + this.editModal.show(); + } + + private isEmptyQuery(q: SearchQueryDTO | null | undefined): boolean { + if (!q) { + return true; + } + return q.type === SearchQueryTypes.any_text && (q as TextSearch).text === ''; + } + + async saveEditUser(): Promise { + try { + if (this.editSettings.overrideAllowBlockList) { + const allowEmpty = this.isEmptyQuery(this.editSettings.allowQuery); + const blockEmpty = this.isEmptyQuery(this.editSettings.blockQuery); + if (allowEmpty && blockEmpty) { + this.notification.error($localize`Please set at least one of Allowed or Blocked query`, $localize`Validation error`); + return; + } + } + + // Save role if changed + if (this.editUser.role !== this.editOriginalUser?.role) { + if(!this.canModifyRole(this.editOriginalUser)){ + this.notification.error($localize`Can't modify user role`); + return; + } + await this.userSettings.updateRole(this.editUser); + } + + const settings: UserSettingsDTO = { + overrideAllowBlockList: this.editSettings.overrideAllowBlockList, + allowQuery: this.editSettings.overrideAllowBlockList ? this.editSettings.allowQuery : null, + blockQuery: this.editSettings.overrideAllowBlockList ? this.editSettings.blockQuery : null, + } as UserSettingsDTO; + if (this.newPassword && this.newPassword.length > 0) { + settings.newPassword = this.newPassword; + } + await this.userSettings.updateSettings(this.editUser.id, settings); + this.notification.success($localize`User settings saved successfully`); + await this.getUsersList(); + this.editModal.hide(); + } catch (e) { + const err: ErrorDTO = e; + this.notification.error( + (err?.message || '') + (err?.details ? ', ' + err.details : ''), + $localize`Could not save user settings` + ); + } + } + private async getUsersList(): Promise { try { this.users = await this.userSettings.getUsers(); diff --git a/src/frontend/app/ui/settings/users/users.service.ts b/src/frontend/app/ui/settings/users/users.service.ts index 63b19170..608805ad 100644 --- a/src/frontend/app/ui/settings/users/users.service.ts +++ b/src/frontend/app/ui/settings/users/users.service.ts @@ -1,6 +1,7 @@ import {Injectable} from '@angular/core'; import {UserDTO} from '../../../../../common/entities/UserDTO'; import {NetworkService} from '../../../model/network/network.service'; +import {UserSettingsDTO} from '../../../../../common/entities/UserSettingsDTO'; @Injectable({ providedIn: 'root' @@ -28,4 +29,10 @@ export class UsersSettingsService { newRole: user.role, }); } + + public updateSettings(userId: number, settings: UserSettingsDTO): Promise { + return this.networkService.postJson('/user/' + userId + '/settings', { + settings + }); + } } diff --git a/src/frontend/main.ts b/src/frontend/main.ts index 0ab77ed6..f0a59de9 100644 --- a/src/frontend/main.ts +++ b/src/frontend/main.ts @@ -44,7 +44,7 @@ import { FormsModule } from '@angular/forms'; import { provideAnimations } from '@angular/platform-browser/animations'; import { AppRoutingModule } from './app/app.routing'; import { NgIconsModule } from '@ng-icons/core'; -import { ionDownloadOutline, ionFunnelOutline, ionGitBranchOutline, ionArrowDownOutline, ionArrowUpOutline, ionStarOutline, ionStar, ionCalendarOutline, ionPersonOutline, ionShuffleOutline, ionPeopleOutline, ionMenuOutline, ionShareSocialOutline, ionImagesOutline, ionLinkOutline, ionSearchOutline, ionHammerOutline, ionCopyOutline, ionAlbumsOutline, ionSettingsOutline, ionLogOutOutline, ionChevronForwardOutline, ionChevronDownOutline, ionChevronBackOutline, ionTrashOutline, ionSaveOutline, ionAddOutline, ionRemoveOutline, ionTextOutline, ionFolderOutline, ionDocumentOutline, ionDocumentTextOutline, ionImageOutline, ionPricetagOutline, ionLocationOutline, ionSunnyOutline, ionMoonOutline, ionVideocamOutline, ionInformationCircleOutline, ionInformationOutline, ionContractOutline, ionExpandOutline, ionCloseOutline, ionTimerOutline, ionPlayOutline, ionPauseOutline, ionVolumeMediumOutline, ionVolumeMuteOutline, ionCameraOutline, ionWarningOutline, ionLockClosedOutline, ionChevronUpOutline, ionFlagOutline, ionGlobeOutline, ionPieChartOutline, ionStopOutline, ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList } from '@ng-icons/ionicons'; +import { ionDownloadOutline, ionFunnelOutline, ionGitBranchOutline, ionArrowDownOutline, ionArrowUpOutline, ionStarOutline, ionStar, ionCalendarOutline, ionPersonOutline, ionShuffleOutline, ionPeopleOutline, ionMenuOutline, ionShareSocialOutline, ionImagesOutline, ionLinkOutline, ionSearchOutline, ionHammerOutline, ionCopyOutline, ionAlbumsOutline, ionSettingsOutline, ionLogOutOutline, ionChevronForwardOutline, ionChevronDownOutline, ionChevronBackOutline, ionTrashOutline, ionSaveOutline, ionAddOutline, ionRemoveOutline, ionTextOutline, ionFolderOutline, ionDocumentOutline, ionDocumentTextOutline, ionImageOutline, ionPricetagOutline, ionLocationOutline, ionSunnyOutline, ionMoonOutline, ionVideocamOutline, ionInformationCircleOutline, ionInformationOutline, ionContractOutline, ionExpandOutline, ionCloseOutline, ionTimerOutline, ionPlayOutline, ionPauseOutline, ionVolumeMediumOutline, ionVolumeMuteOutline, ionCameraOutline, ionWarningOutline, ionLockClosedOutline, ionChevronUpOutline, ionFlagOutline, ionGlobeOutline, ionPieChartOutline, ionStopOutline, ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList, ionPencil } from '@ng-icons/ionicons'; import { ClipboardModule } from 'ngx-clipboard'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { ToastrModule } from 'ngx-toastr'; @@ -123,7 +123,7 @@ bootstrapApplication(AppComponent, { ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, - ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList + ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList, ionPencil }), ClipboardModule, TooltipModule.forRoot(), ToastrModule.forRoot(), ModalModule.forRoot(), CollapseModule.forRoot(), PopoverModule.forRoot(), BsDropdownModule.forRoot(), BsDatepickerModule.forRoot(), TimepickerModule.forRoot(), LoadingBarModule, LeafletModule, LeafletMarkerClusterModule, MarkdownModule.forRoot({ loader: HttpClient })), { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, { provide: UrlSerializer, useClass: CustomUrlSerializer }, diff --git a/test/common/unit/SearchQueryDTOUtils.spec.ts b/test/common/unit/SearchQueryDTOUtils.spec.ts index 7fe3e777..ebebce83 100644 --- a/test/common/unit/SearchQueryDTOUtils.spec.ts +++ b/test/common/unit/SearchQueryDTOUtils.spec.ts @@ -3,16 +3,16 @@ import { ANDSearchQuery, ORSearchQuery, SearchQueryDTO, - SearchQueryDTOUtils, SearchQueryTypes, SomeOfSearchQuery, TextSearch, TextSearchQueryMatchTypes, } from '../../../src/common/entities/SearchQueryDTO'; +import { SearchQueryUtils } from '../../../src/common/SearchQueryUtils'; const eq = (a: SearchQueryDTO, b: SearchQueryDTO) => { - const sa = SearchQueryDTOUtils.stringifyForComparison(a); - const sb = SearchQueryDTOUtils.stringifyForComparison(b); + const sa = SearchQueryUtils.stringifyForComparison(a); + const sb = SearchQueryUtils.stringifyForComparison(b); expect(sa).to.equal(sb); }; @@ -26,7 +26,7 @@ describe('SearchQueryDTOUtils.sortQuery', () => { eq(q1, q2); - const s = SearchQueryDTOUtils.sortQuery(q2) as ANDSearchQuery; + const s = SearchQueryUtils.sortQuery(q2) as ANDSearchQuery; expect((s.list[0] as TextSearch).text).to.equal('alpha'); expect((s.list[1] as TextSearch).text).to.equal('bob'); }); @@ -39,7 +39,7 @@ describe('SearchQueryDTOUtils.sortQuery', () => { eq(q1, q2); - const s = SearchQueryDTOUtils.sortQuery(q1) as ORSearchQuery; + const s = SearchQueryUtils.sortQuery(q1) as ORSearchQuery; expect(s.list.map((c) => (c as TextSearch).text)).to.deep.equal(['holidays', 'summer'].sort()); }); }); @@ -63,12 +63,12 @@ describe('SearchQueryDTOUtils.sortQuery', () => { eq(q1, q2); - const s = SearchQueryDTOUtils.sortQuery(q1) as SomeOfSearchQuery; + const s = SearchQueryUtils.sortQuery(q1) as SomeOfSearchQuery; expect(s.min).to.equal(2); expect(s.list).to.have.length(3); // Ensure all children are present with the same negate flags - const serializedChildren = s.list.map((c) => SearchQueryDTOUtils.stringifyForComparison(c)); - const expected = [x1, x2, x3].map((c) => SearchQueryDTOUtils.stringifyForComparison(c)); + const serializedChildren = s.list.map((c) => SearchQueryUtils.stringifyForComparison(c)); + const expected = [x1, x2, x3].map((c) => SearchQueryUtils.stringifyForComparison(c)); expect(serializedChildren.sort()).to.deep.equal(expected.sort()); // Negate flag stayed on the same semantic child (person:y) @@ -107,7 +107,7 @@ describe('SearchQueryDTOUtils.sortQuery', () => { eq(q1, q2); // Ensure values are preserved after sort - const s = SearchQueryDTOUtils.sortQuery(q1) as any; + const s = SearchQueryUtils.sortQuery(q1) as any; expect(s.distance).to.equal(5); expect(s.from.GPSData.latitude).to.equal(20.654321); expect(s.from.GPSData.longitude).to.equal(10.123456);
{{user.name}} - - - {{user.role | stringifyRole}} + {{ user.name }} + {{ user.role | stringifyRole }} - +