1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2025-11-25 22:32:52 +02:00

Implement search query based sharing on the backend #1015

This commit is contained in:
Patrik J. Braun
2025-08-17 11:43:29 +02:00
parent 41f57e29db
commit 31ef7e8470
30 changed files with 568 additions and 353 deletions

View File

@@ -4,6 +4,8 @@ Author: Junie (JetBrains autonomous programmer)
Date: 2025-08-13
Status: Updated after maintainer clarifications
NOTE: scoped working is the same as projection. THe app uses the projection terminologie instead of scoped.
## Overview
This document proposes a design to support:
- Search-based sharing: a share link represents a pre-filtered view of the gallery.

View File

@@ -8,7 +8,6 @@ import {ObjectManagers} from '../model/ObjectManagers';
import {ContentWrapper} from '../../common/entities/ConentWrapper';
import {ProjectPath} from '../ProjectPath';
import {Config} from '../../common/config/private/Config';
import {UserDTOUtils} from '../../common/entities/UserDTO';
import {MediaDTO, MediaDTOUtils} from '../../common/entities/MediaDTO';
import {QueryParams} from '../../common/QueryParams';
import {VideoProcessing} from '../model/fileaccess/fileprocessing/VideoProcessing';
@@ -57,15 +56,6 @@ export class GalleryMWs {
req.resultPipe = new ContentWrapper(null, null, true);
return next();
}
if (
req.session.context?.user.permissions &&
req.session.context?.user.permissions.length > 0 &&
req.session.context?.user.permissions[0] !== '/*'
) {
directory.directories = directory.directories.filter((d): boolean =>
UserDTOUtils.isDirectoryAvailable(d, req.session.context.user.permissions)
);
}
req.resultPipe = new ContentWrapper(directory, null);
return next();
} catch (err) {

View File

@@ -40,7 +40,6 @@ export class RenderingMWs {
name: req.session.context.user.name,
role: req.session.context.user.role,
usedSharingKey: req.session.context.user.usedSharingKey,
permissions: req.session.context.user.permissions,
projectionKey: req.session.context.user.projectionKey,
} as UserDTO;

View File

@@ -6,6 +6,7 @@ import {Config} from '../../common/config/private/Config';
import {QueryParams} from '../../common/QueryParams';
import * as path from 'path';
import {UserRoles} from '../../common/entities/UserDTO';
import {SearchQueryDTO, SearchQueryTypes, TextSearch, TextSearchQueryMatchTypes} from '../../common/entities/SearchQueryDTO';
export class SharingMWs {
public static async getSharing(
@@ -97,17 +98,25 @@ export class SharingMWs {
}
const directoryName = path.normalize(req.params['directory'] || '/');
// Prefer provided searchQuery; otherwise fallback to strict directory exact-match query for compatibility
const searchQuery = createSharing.searchQuery || ({
type: SearchQueryTypes.directory,
text: directoryName,
matchType: TextSearchQueryMatchTypes.exact_match,
negate: false
} as TextSearch);
const sharing: SharingDTO = {
id: null,
sharingKey,
path: directoryName,
searchQuery,
password: createSharing.password,
creator: req.session.context?.user,
expires:
createSharing.valid >= 0 // if === -1 it's forever
? Date.now() + createSharing.valid
: new Date(9999, 0, 1).getTime(), // never expire
includeSubfolders: createSharing.includeSubfolders,
timeStamp: Date.now(),
};
@@ -147,9 +156,17 @@ export class SharingMWs {
}
const updateSharing: CreateSharingDTO = req.body.updateSharing;
const directoryName = path.normalize(req.params['directory'] || '/');
const searchQuery = updateSharing.searchQuery || ({
type: SearchQueryTypes.directory,
text: directoryName,
matchType: TextSearchQueryMatchTypes.exact_match,
negate: false
} as TextSearch);
const sharing: SharingDTO = {
id: updateSharing.id,
path: directoryName,
searchQuery,
sharingKey: '',
password:
updateSharing.password && updateSharing.password !== ''
@@ -160,7 +177,6 @@ export class SharingMWs {
updateSharing.valid >= 0 // if === -1 its forever
? Date.now() + updateSharing.valid
: new Date(9999, 0, 1).getTime(), // never expire
includeSubfolders: updateSharing.includeSubfolders,
timeStamp: Date.now(),
};
@@ -249,7 +265,7 @@ export class SharingMWs {
}
}
public static async listSharingForDir(
public static async listSharingForQuery(
req: Request,
res: Response,
next: NextFunction
@@ -257,15 +273,16 @@ export class SharingMWs {
if (Config.Sharing.enabled === false) {
return next();
}
const dir = path.normalize(req.params['directory'] || '/');
const query: SearchQueryDTO = JSON.parse(
req.params['searchQueryDTO'] as string
);
try {
if (req.session.context?.user.role >= UserRoles.Admin) {
req.resultPipe =
await ObjectManagers.getInstance().SharingManager.listAllForDir(dir);
await ObjectManagers.getInstance().SharingManager.listAllForQuery(query);
} else {
req.resultPipe =
await ObjectManagers.getInstance().SharingManager.listAllForDir(dir, req.session.context?.user);
await ObjectManagers.getInstance().SharingManager.listAllForQuery(query, req.session.context?.user);
}
return next();
} catch (err) {

View File

@@ -1,6 +1,6 @@
import {NextFunction, Request, Response} from 'express';
import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error';
import {UserDTO, UserDTOUtils, UserRoles,} from '../../../common/entities/UserDTO';
import {UserDTO, UserRoles,} from '../../../common/entities/UserDTO';
import {ObjectManagers} from '../../model/ObjectManagers';
import {Config} from '../../../common/config/private/Config';
import {PasswordHelper} from '../../model/PasswordHelper';
@@ -10,6 +10,8 @@ import * as path from 'path';
import {Logger} from '../../Logger';
import {SQLConnection} from '../../model/database/SQLConnection';
import {MediaEntity} from '../../model/database/enitites/MediaEntity';
import {UserEntity} from '../../model/database/enitites/UserEntity';
import {ContextUser} from '../../model/SessionContext';
const LOG_TAG = 'AuthenticationMWs';
@@ -93,43 +95,31 @@ export class AuthenticationMWs {
};
}
// Legacy authorisePath kept for compatibility; prefer new specific middlewares below
private static authorisePath(
paramName: string,
isDirectory: boolean
): (req: Request, res: Response, next: NextFunction) => void {
return function authorisePath(
req: Request,
res: Response,
next: NextFunction
): Response | void {
let p: string = req.params[paramName];
if (!isDirectory) {
p = path.dirname(p);
}
if (
!UserDTOUtils.isDirectoryPathAvailable(p, req.session.context.user.permissions)
) {
return res.sendStatus(403);
}
return next();
};
}
// New middlewares
public static authoriseDirectories(
paramName: string
): (req: Request, res: Response, next: NextFunction) => void {
return AuthenticationMWs.authorisePath(paramName, true);
}
public static authoriseMetaFiles(
paramName: string
): (req: Request, res: Response, next: NextFunction) => void {
// For metafiles: same behavior as legacy (check directory permission only)
return AuthenticationMWs.authorisePath(paramName, false);
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async function authoriseMetaFiles(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const p: string = req.params[paramName];
if (!await ObjectManagers.getInstance().GalleryManager.authoriseMetaFile(req.session.context, p)) {
res.sendStatus(403);
return;
}
return next();
} catch (e) {
// On error, fail closed to be safe
Logger.warn(LOG_TAG, 'authoriseMedia error:', e);
res.sendStatus(403);
return;
}
};
}
public static authoriseMedia(
@@ -142,12 +132,6 @@ export class AuthenticationMWs {
): Promise<void> {
try {
const mediaRelPath: string = req.params[paramName];
// First: directory permission check (same as legacy for files)
const dirRelPath = path.dirname(mediaRelPath);
if (!UserDTOUtils.isDirectoryPathAvailable(dirRelPath, req.session.context.user.permissions)) {
res.sendStatus(403);
return;
}
if (!await ObjectManagers.getInstance().GalleryManager.authoriseMedia(req.session.context, mediaRelPath)) {
res.sendStatus(403);
@@ -217,17 +201,13 @@ export class AuthenticationMWs {
return next(new ErrorDTO(ErrorCodes.CREDENTIAL_NOT_FOUND));
}
let sharingPath = sharing.path;
if (sharing.includeSubfolders === true) {
sharingPath += '*';
}
const user = {
name: 'Guest',
role: UserRoles.LimitedGuest,
permissions: [sharingPath],
usedSharingKey: sharing.sharingKey,
} as UserDTO;
overrideAllowBlockList: true,
allowQuery: ObjectManagers.getInstance().buildAllowListForSharing(sharing)
} as ContextUser;
req.session.context = await ObjectManagers.getInstance().buildContext(user);
return next();
} catch (err) {
@@ -304,7 +284,7 @@ export class AuthenticationMWs {
return next();
}
private static async getSharingUser(req: Request): Promise<UserDTO> {
private static async getSharingUser(req: Request): Promise<ContextUser> {
if (
Config.Sharing.enabled === true &&
(!!req.query[QueryParams.gallery.sharingKey_query] ||
@@ -326,16 +306,13 @@ export class AuthenticationMWs {
return null;
}
let sharingPath = sharing.path;
if (sharing.includeSubfolders === true) {
sharingPath += '*';
}
return {
name: 'Guest',
role: UserRoles.LimitedGuest,
permissions: [sharingPath],
usedSharingKey: sharing.sharingKey,
} as UserDTO;
overrideAllowBlockList: true,
allowQuery: ObjectManagers.getInstance().buildAllowListForSharing(sharing)
} as ContextUser;
}
return null;
}

View File

@@ -16,10 +16,11 @@ import {PersonManager} from './database/PersonManager';
import {SharingManager} from './database/SharingManager';
import {IObjectManager} from './database/IObjectManager';
import {ExtensionManager} from './extension/ExtensionManager';
import {SessionContext} from './SessionContext';
import {ContextUser, SessionContext} from './SessionContext';
import {UserEntity} from './database/enitites/UserEntity';
import {ANDSearchQuery, SearchQueryDTOUtils, SearchQueryTypes} from '../../common/entities/SearchQueryDTO';
import {ANDSearchQuery, SearchQueryDTO, SearchQueryDTOUtils, SearchQueryTypes} from '../../common/entities/SearchQueryDTO';
import {Config} from '../../common/config/private/Config';
import {SharingEntity} from './database/enitites/SharingEntity';
const LOG_TAG = '[ObjectManagers]';
@@ -275,30 +276,55 @@ export class ObjectManagers {
this.managers.push(this.extensionManager as IObjectManager);
}
async buildContext(user: UserEntity): Promise<SessionContext> {
const context = new SessionContext();
context.user = user;
private getQueryForUser(user: ContextUser) {
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)) {
return null;
}
if (SearchQueryDTOUtils.isValidQuery(blockQuery)) {
blockQuery = SearchQueryDTOUtils.negate(blockQuery);
}
if (SearchQueryDTOUtils.isValidQuery(allowQuery) || SearchQueryDTOUtils.isValidQuery(blockQuery)) {
let query = allowQuery || blockQuery;
if (allowQuery && blockQuery) {
query = {
type: SearchQueryTypes.AND,
list: [
allowQuery,
blockQuery
]
} as ANDSearchQuery;
}
console.log(allowQuery, blockQuery, query);
// Build the Brackets-based query
context.projectionQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(query);
let query = SearchQueryDTOUtils.isValidQuery(allowQuery) ? allowQuery : blockQuery;
if (SearchQueryDTOUtils.isValidQuery(allowQuery) && SearchQueryDTOUtils.isValidQuery(blockQuery)) {
query = {
type: SearchQueryTypes.AND,
list: [
allowQuery,
blockQuery
]
} as ANDSearchQuery;
}
return query;
context.user.projectionKey = crypto.createHash('md5').update(JSON.stringify(query)).digest('hex');
}
public buildAllowListForSharing(sharing:SharingEntity): SearchQueryDTO {
const creatorQuery = this.getQueryForUser(sharing.creator);
let finalQuery = sharing.searchQuery;
if(creatorQuery){
finalQuery = {
type: SearchQueryTypes.AND,
list: [
creatorQuery,
sharing.searchQuery
]
} as ANDSearchQuery;
}
return finalQuery;
}
public async buildContext(user: ContextUser): Promise<SessionContext> {
const context = new SessionContext();
context.user = user;
let finalQuery = this.getQueryForUser(user);
if (finalQuery) {
// Build the Brackets-based query
context.projectionQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery);
context.user.projectionKey = crypto.createHash('md5').update(JSON.stringify(finalQuery)).digest('hex');
}
return context;
}

View File

@@ -1,8 +1,17 @@
import {Brackets} from 'typeorm';
import {UserDTO} from '../../common/entities/UserDTO';
import {SearchQueryDTO} from '../../common/entities/SearchQueryDTO';
export class SessionContext {
user: UserDTO;
user: ContextUser;
// New structured projection with prebuilt SQL and params
projectionQuery?: Brackets;
}
export interface ContextUser extends UserDTO {
overrideAllowBlockList?: boolean;
allowQuery?: SearchQueryDTO;
blockQuery?: SearchQueryDTO;
}

View File

@@ -445,4 +445,31 @@ export class GalleryManager {
return count !== 0;
}
async authoriseMetaFile(session: SessionContext, p: string) {
// If no projection set for session, proceed
if (!session?.projectionQuery) {
return true;
}
// Authorize metafile if its directory contains any media that matches the projectionQuery
const dirRelPath = path.dirname(p);
const directoryName = path.basename(dirRelPath);
const directoryParent = path.join(path.dirname(dirRelPath), path.sep);
const connection = await SQLConnection.getConnection();
const qb = connection
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.where('directory.name = :dname AND directory.path = :dpath', {
dname: directoryName,
dpath: directoryParent,
})
.andWhere(session.projectionQuery);
const count = await qb.getCount();
return count !== 0;
}
}

View File

@@ -5,23 +5,24 @@ import {Config} from '../../../common/config/private/Config';
import {PasswordHelper} from '../PasswordHelper';
import {DeleteResult, SelectQueryBuilder} from 'typeorm';
import {UserDTO} from '../../../common/entities/UserDTO';
import {SearchQueryDTO} from '../../../common/entities/SearchQueryDTO';
export class SharingManager {
private static async removeExpiredLink(): Promise<DeleteResult> {
const connection = await SQLConnection.getConnection();
return await connection
.getRepository(SharingEntity)
.createQueryBuilder('share')
.where('expires < :now', {now: Date.now()})
.delete()
.execute();
.getRepository(SharingEntity)
.createQueryBuilder('share')
.where('expires < :now', {now: Date.now()})
.delete()
.execute();
}
async deleteSharing(sharingKey: string): Promise<void> {
const connection = await SQLConnection.getConnection();
const sharing = await connection
.getRepository(SharingEntity)
.findOneBy({sharingKey});
.getRepository(SharingEntity)
.findOneBy({sharingKey});
await connection.getRepository(SharingEntity).remove(sharing);
}
@@ -29,35 +30,35 @@ export class SharingManager {
await SharingManager.removeExpiredLink();
const connection = await SQLConnection.getConnection();
return await connection
.getRepository(SharingEntity)
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.getMany();
.getRepository(SharingEntity)
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.getMany();
}
async listAllForDir(dir: string, user?: UserDTO): Promise<SharingDTO[]> {
async listAllForQuery(query: SearchQueryDTO, user?: UserDTO): Promise<SharingDTO[]> {
await SharingManager.removeExpiredLink();
const connection = await SQLConnection.getConnection();
const q: SelectQueryBuilder<SharingEntity> = connection
.getRepository(SharingEntity)
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.where('path = :dir', {dir});
.getRepository(SharingEntity)
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.where('share.searchQuery = :query', {query: JSON.stringify(query)});
if (user) {
q.andWhere('share.creator = :user', {user: user.id});
q.where('share.creator = :user', {user: user.id});
}
return await q.getMany();
}
async findOne(sharingKey: string): Promise<SharingDTO> {
async findOne(sharingKey: string): Promise<SharingEntity> {
await SharingManager.removeExpiredLink();
const connection = await SQLConnection.getConnection();
return await connection.getRepository(SharingEntity)
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.where('share.sharingKey = :sharingKey', {sharingKey})
.getOne();
.createQueryBuilder('share')
.leftJoinAndSelect('share.creator', 'creator')
.where('share.sharingKey = :sharingKey', {sharingKey})
.getOne();
}
async createSharing(sharing: SharingDTO): Promise<SharingDTO> {
@@ -70,20 +71,19 @@ export class SharingManager {
}
async updateSharing(
inSharing: SharingDTO,
forceUpdate: boolean
inSharing: SharingDTO,
forceUpdate: boolean
): Promise<SharingDTO> {
const connection = await SQLConnection.getConnection();
const sharing = await connection.getRepository(SharingEntity).findOneBy({
id: inSharing.id,
creator: inSharing.creator.id as unknown,
path: inSharing.path,
});
if (
sharing.timeStamp < Date.now() - Config.Sharing.updateTimeout &&
forceUpdate !== true
sharing.timeStamp < Date.now() - Config.Sharing.updateTimeout &&
forceUpdate !== true
) {
throw new Error('Sharing is locked, can\'t update anymore');
}
@@ -92,7 +92,8 @@ export class SharingManager {
} else {
sharing.password = PasswordHelper.cryptPassword(inSharing.password);
}
sharing.includeSubfolders = inSharing.includeSubfolders;
// allow updating searchQuery
(sharing as any).searchQuery = (inSharing as any).searchQuery || (sharing as any).searchQuery;
sharing.expires = inSharing.expires;
return connection.getRepository(SharingEntity).save(sharing);

View File

@@ -2,6 +2,7 @@ import {Column, Entity, ManyToOne, PrimaryGeneratedColumn} from 'typeorm';
import {SharingDTO} from '../../../../common/entities/SharingDTO';
import {UserEntity} from './UserEntity';
import {UserDTO} from '../../../../common/entities/UserDTO';
import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO';
@Entity()
export class SharingEntity implements SharingDTO {
@@ -11,8 +12,19 @@ export class SharingEntity implements SharingDTO {
@Column()
sharingKey: string;
@Column()
path: string;
@Column({
type: 'text',
nullable: false,
transformer: {
from: (val: string) => {
return val ? JSON.parse(val) : null;
},
to: (val: object) => {
return val ? JSON.stringify(val) : null;
},
},
})
searchQuery: SearchQueryDTO;
@Column({type: 'text', nullable: true})
password: string;
@@ -35,9 +47,6 @@ export class SharingEntity implements SharingDTO {
})
timeStamp: number;
@Column()
includeSubfolders: boolean;
@ManyToOne(() => UserEntity, {onDelete: 'CASCADE', nullable: false})
creator: UserDTO;
creator: UserEntity;
}

View File

@@ -1,10 +1,11 @@
import {UserDTO, UserRoles} from '../../../../common/entities/UserDTO';
import {Column, Entity, PrimaryGeneratedColumn, Unique} from 'typeorm';
import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO';
import {ContextUser} from '../../SessionContext';
@Entity()
@Unique(['name'])
export class UserEntity implements UserDTO {
export class UserEntity implements UserDTO,ContextUser {
@PrimaryGeneratedColumn()
id: number;
@@ -17,9 +18,6 @@ export class UserEntity implements UserDTO {
@Column('smallint')
role: UserRoles;
@Column('simple-array', {nullable: true})
permissions: string[];
// only if this set to true, will the per-user allow/blocklist be considered
@Column({type: 'boolean', default: false})
overrideAllowBlockList?: boolean;

View File

@@ -36,7 +36,6 @@ export class GalleryRouter {
// common part
AuthenticationMWs.authenticate,
AuthenticationMWs.normalizePathParam('directory'),
AuthenticationMWs.authoriseDirectories('directory'),
VersionMWs.injectGalleryVersion,
// specific part

View File

@@ -80,8 +80,7 @@ export class PublicRouter {
name: req.session.context.user.name,
role: req.session.context.user.role,
usedSharingKey: req.session.context.user.usedSharingKey,
permissions:req.session.context.user.permissions,
projectionKey:req.session.context.user.projectionKey,
projectionKey: req.session.context.user.projectionKey,
} as UserDTO;
}

View File

@@ -101,12 +101,10 @@ export class SharingRouter {
private static addListSharingForDir(app: express.Express): void {
app.get(
[Config.Server.apiPath + '/share/list/:directory(*)',
Config.Server.apiPath + '/share/list//',
Config.Server.apiPath + '/share/list'],
[Config.Server.apiPath + '/share/list/:searchQueryDTO'],
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.User),
SharingMWs.listSharingForDir,
SharingMWs.listSharingForQuery,
ServerTimingMWs.addServerTiming,
RenderingMWs.renderSharingList
);

View File

@@ -1,4 +1,5 @@
import {UserDTO} from './UserDTO';
import {SearchQueryDTO} from './SearchQueryDTO';
export interface SharingDTOKey {
sharingKey: string;
@@ -6,18 +7,17 @@ export interface SharingDTOKey {
export interface SharingDTO extends SharingDTOKey {
id: number;
path: string;
searchQuery: SearchQueryDTO;
sharingKey: string;
password: string;
password?: string;
expires: number;
timeStamp: number;
includeSubfolders: boolean;
creator: UserDTO;
creator?: UserDTO;
}
export interface CreateSharingDTO {
id?: number;
password: string;
valid: number;
includeSubfolders: boolean;
searchQuery: SearchQueryDTO;
}

View File

@@ -1,6 +1,3 @@
import {DirectoryPathDTO} from './DirectoryDTO';
import {Utils} from '../Utils';
export enum UserRoles {
LimitedGuest = 1,
Guest = 2,
@@ -12,51 +9,8 @@ export enum UserRoles {
export interface UserDTO {
id: number;
name: string;
password: string;
password?: string;
role: UserRoles;
usedSharingKey?: string;
permissions: string[]; // user can only see these permissions. if ends with *, its recursive
projectionKey?: string; // allow- and blocklist projection hash. if null, no projection
}
export const UserDTOUtils = {
isDirectoryPathAvailable: (path: string, permissions: string[]): boolean => {
if (permissions == null) {
return true;
}
permissions = permissions.map((p) => Utils.canonizePath(p));
path = Utils.canonizePath(path);
if (permissions.length === 0 || permissions[0] === '/*') {
return true;
}
for (let permission of permissions) {
if (permission === '/*') {
return true;
}
if (permission[permission.length - 1] === '*') {
permission = permission.slice(0, -1);
if (
path.startsWith(permission) &&
(!path[permission.length] || path[permission.length] === '/')
) {
return true;
}
} else if (path === permission) {
return true;
} else if (path === '.' && permission === '/') {
return true;
}
}
return false;
},
isDirectoryAvailable: (
directory: DirectoryPathDTO,
permissions: string[]
): boolean => {
return UserDTOUtils.isDirectoryPathAvailable(
Utils.concatUrls(directory.path, directory.name),
permissions
);
},
};

View File

@@ -183,7 +183,7 @@ export class GalleryComponent implements OnInit, OnDestroy {
qParams[QueryParams.gallery.sharingKey_query] =
this.shareService.getSharingKey();
this.router
.navigate(['/gallery', sharing.path], {queryParams: qParams})
.navigate(['/search', sharing.searchQuery], {queryParams: qParams})
.catch(console.error);
return;
}

View File

@@ -1,7 +1,7 @@
import {Component, ElementRef, HostListener, ViewChild} from '@angular/core';
import {Router, RouterLink} from '@angular/router';
import {DomSanitizer} from '@angular/platform-browser';
import {UserDTOUtils} from '../../../../../common/entities/UserDTO';
import {UserRoles} from '../../../../../common/entities/UserDTO';
import {AuthenticationService} from '../../../model/network/authentication.service';
import {QueryService} from '../../../model/query.service';
import {Utils} from '../../../../../common/Utils';
@@ -137,7 +137,7 @@ export class GalleryNavigatorComponent {
} else {
arr.push({
name: this.RootFolderName,
route: UserDTOUtils.isDirectoryPathAvailable('/', user.permissions)
route: user.role <= UserRoles.LimitedGuest // it's basically a sharing. they should not just navigate wherever
? '/'
: null,
});
@@ -151,7 +151,7 @@ export class GalleryNavigatorComponent {
} else {
arr.push({
name,
route: UserDTOUtils.isDirectoryPathAvailable(route, user.permissions)
route: user.role <= UserRoles.LimitedGuest // it's basically a sharing. they should not just navigate wherever
? route
: null,
});

View File

@@ -8,6 +8,7 @@ import {QueryParams} from '../../../../common/QueryParams';
import {UserDTO, UserRoles} from '../../../../common/entities/UserDTO';
import {Utils} from '../../../../common/Utils';
import {Config} from '../../../../common/config/public/Config';
import {SearchQueryTypes, TextSearch, TextSearchQueryMatchTypes} from '../../../../common/entities/SearchQueryDTO';
@Injectable()
@@ -80,8 +81,8 @@ export class ShareService {
this.sharingSubject.value == null
) {
this.sharingKey = user.usedSharingKey || this.getSharingKey();
if(!this.sharingKey){ //no key to fetch
return
if (!this.sharingKey) { //no key to fetch
return;
}
await this.getSharing();
}
@@ -102,13 +103,11 @@ export class ShareService {
public createSharing(
dir: string,
includeSubFolders: boolean,
password: string,
valid: number
): Promise<SharingDTO> {
return this.networkService.postJson('/share/' + dir, {
createSharing: {
includeSubfolders: includeSubFolders,
valid,
...(!!password && {password: password}) // only add password if present
} as CreateSharingDTO,
@@ -118,14 +117,12 @@ export class ShareService {
public updateSharing(
dir: string,
sharingId: number,
includeSubFolders: boolean,
password: string,
valid: number
): Promise<SharingDTO> {
return this.networkService.putJson('/share/' + dir, {
updateSharing: {
id: sharingId,
includeSubfolders: includeSubFolders,
valid,
password,
} as CreateSharingDTO,
@@ -169,7 +166,11 @@ export class ShareService {
public async getSharingListForDir(
dir: string
): Promise<SharingDTO[]> {
return this.networkService.getJson('/share/list/' + dir);
return this.networkService.getJson('/share/list/' + JSON.stringify({
type: SearchQueryTypes.directory,
text: dir,
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch));
}

View File

@@ -37,7 +37,6 @@ export class GalleryShareComponent implements OnInit, OnDestroy {
showSharingList = false;
input = {
includeSubfolders: true,
valid: {
amount: 30,
type: ValidityTypes.Days as ValidityTypes,
@@ -139,7 +138,6 @@ export class GalleryShareComponent implements OnInit, OnDestroy {
this.sharing = await this.sharingService.updateSharing(
this.currentDir,
this.sharing.id,
this.input.includeSubfolders,
this.input.password,
this.calcValidity()
);
@@ -157,7 +155,6 @@ export class GalleryShareComponent implements OnInit, OnDestroy {
this.url = $localize`loading..`;
this.sharing = await this.sharingService.createSharing(
this.currentDir,
this.input.includeSubfolders,
this.input.password,
this.calcValidity()
);

View File

@@ -13,6 +13,7 @@ import {TestHelper} from '../../../TestHelper';
import {ProjectPath} from '../../../../src/backend/ProjectPath';
import * as chai from "chai";
import {default as chaiHttp, request} from "chai-http";
import {DBTestHelper} from '../../DBTestHelper';
process.env.NODE_ENV = 'test';
chai.should();
@@ -20,24 +21,21 @@ const {expect} = chai;
chai.use(chaiHttp);
describe('PublicRouter', () => {
const sqlHelper = new DBTestHelper(DatabaseType.sqlite);
const testUser: UserDTO = {
id: 1,
name: 'test',
password: 'test',
role: UserRoles.User,
permissions: null
role: UserRoles.User
};
const {password: pass, ...expectedUser} = testUser;
let server: Server;
const setUp = async () => {
await fs.promises.rm(TestHelper.TMP_DIR, {recursive: true, force: true});
await sqlHelper.initDB();
Config.Users.authenticationRequired = true;
Config.Sharing.enabled = true;
Config.Database.type = DatabaseType.sqlite;
Config.Database.dbFolder = TestHelper.TMP_DIR;
ProjectPath.reset();
server = new Server(false);
await server.onStarted.wait();
@@ -47,8 +45,7 @@ describe('PublicRouter', () => {
await SQLConnection.close();
};
const tearDown = async () => {
await ObjectManagers.reset();
await fs.promises.rm(TestHelper.TMP_DIR, {recursive: true, force: true});
await sqlHelper.clearDB();
};
const shouldHaveInjectedUser = (result: any, user: any) => {
@@ -61,6 +58,17 @@ describe('PublicRouter', () => {
const u = JSON.parse(result.text.substring(result.text.indexOf(startToken) + startToken.length, result.text.indexOf(endToken)));
if (user == null) {
expect(u).to.equal(null);
return;
}
// Only public-safe subset is injected; ensure projectionKey is present but do not assert its value
expect(u).to.be.an('object');
expect(u).to.have.property('name', user.name);
expect(u).to.have.property('role', user.role);
expect(u).to.have.property('usedSharingKey', user.usedSharingKey);
expect(u).to.have.property('projectionKey');
expect(u.projectionKey).to.be.a('string').and.to.have.length.greaterThan(0);
expect(u).to.deep.equal(user);
};
@@ -94,13 +102,9 @@ describe('PublicRouter', () => {
Config.Sharing.passwordRequired = false;
const sharing = await RouteTestingHelper.createSharing(testUser);
const res = await fistLoad(server, sharing.sharingKey);
shouldHaveInjectedUser(res, RouteTestingHelper.getExpectedSharingUser(sharing));
shouldHaveInjectedUser(res, RouteTestingHelper.getExpectedSharingUserForUI(sharing));
});
});
});

View File

@@ -2,6 +2,11 @@ import {SharingDTO} from '../../../../src/common/entities/SharingDTO';
import {ObjectManagers} from '../../../../src/backend/model/ObjectManagers';
import {UserDTO, UserRoles} from '../../../../src/common/entities/UserDTO';
import {Utils} from '../../../../src/common/Utils';
import {SearchQueryTypes, TextSearch, TextSearchQueryMatchTypes} from '../../../../src/common/entities/SearchQueryDTO';
import * as chai from 'chai';
import * as crypto from 'crypto';
const should = chai.should();
export class RouteTestingHelper {
@@ -9,25 +14,62 @@ export class RouteTestingHelper {
static async createSharing(testUser: UserDTO, password: string = null): Promise<SharingDTO> {
const sharing = {
sharingKey: 'sharing_test_key_' + Date.now(),
path: 'test',
searchQuery: {type: SearchQueryTypes.directory, text: 'test', matchType: TextSearchQueryMatchTypes.exact_match} as TextSearch,
expires: Date.now() + 1000,
timeStamp: Date.now(),
includeSubfolders: false,
creator: testUser
} as SharingDTO;
} as any;
if (password) {
sharing.password = password;
}
await ObjectManagers.getInstance().SharingManager.createSharing(Utils.clone(sharing)); // do not rewrite password
await ObjectManagers.getInstance().SharingManager.createSharing(Utils.clone(sharing)); // do not rewrite the password
return sharing;
}
public static getExpectedSharingUser(sharing: SharingDTO): UserDTO {
return {
public static getExpectedSharingUserForUI(sharing: SharingDTO): UserDTO {
const u = {
name: 'Guest',
role: UserRoles.LimitedGuest,
permissions: [sharing.path],
usedSharingKey: sharing.sharingKey
usedSharingKey: sharing.sharingKey,
} as UserDTO;
const q = ObjectManagers.getInstance().buildAllowListForSharing(sharing as any);
u.projectionKey = crypto.createHash('md5').update(JSON.stringify(q)).digest('hex');
return u;
}
/**
* Check if the result sent to UI is a valid user object
* @param result
* @param user
*/
public static shouldBeValidUIUser = (result: any, user: any) => {
result.should.have.status(200);
result.body.should.be.a('object');
should.equal(result.body.error, null);
const {...u} = result.body.result;
// Ensure sensitive fields are not leaked
(u as any).should.not.have.property('password');
// Ensure server does not leak internal allow/block queries
(u as any).should.not.have.property('allowQuery');
(u as any).should.not.have.property('blockQuery');
(u as any).should.not.have.property('overrideAllowBlockList');
// Check core identity fields
(u as any).should.have.property('name', user.name);
(u as any).should.have.property('role', user.role);
if (typeof user.id !== 'undefined') {
(u as any).should.have.property('id', user.id);
}
if (typeof user.usedSharingKey !== 'undefined') {
(u as any).should.have.property('usedSharingKey', user.usedSharingKey);
}
// projectionKey may be present; if present, ensure it is a non-empty string
if (typeof (u as any).projectionKey !== 'undefined') {
(u as any).projectionKey.should.be.a('string');
((u as any).projectionKey as string).length.should.be.greaterThan(0);
}
u.should.deep.equal(user);
};
}

View File

@@ -25,8 +25,7 @@ describe('SharingRouter', () => {
id: 1,
name: 'test',
password: 'test',
role: UserRoles.User,
permissions: null
role: UserRoles.User
};
const {password: pass, ...expectedUser} = testUser;
const tempDir = path.join(__dirname, '../../tmp');
@@ -51,14 +50,6 @@ describe('SharingRouter', () => {
await fs.promises.rm(tempDir, {recursive: true, force: true});
};
const shouldBeValidUser = (result: any, user: any) => {
result.should.have.status(200);
result.body.should.be.a('object');
should.equal(result.body.error, null);
const {...u} = result.body.result;
u.should.deep.equal(user);
};
const shareLogin = async (srv: Server, sharingKey: string, password?: string): Promise<any> => {
return (request.execute(srv.Server) as SuperAgentStatic)
@@ -77,14 +68,14 @@ describe('SharingRouter', () => {
Config.Sharing.passwordRequired = true;
const sharing = await RouteTestingHelper.createSharing(testUser, 'secret_pass');
const res = await shareLogin(server, sharing.sharingKey, sharing.password);
shouldBeValidUser(res, RouteTestingHelper.getExpectedSharingUser(sharing));
RouteTestingHelper.shouldBeValidUIUser(res, RouteTestingHelper.getExpectedSharingUserForUI(sharing));
});
it('should login with passworded share when password not required', async () => {
Config.Sharing.passwordRequired = false;
const sharing = await RouteTestingHelper.createSharing(testUser, 'secret_pass');
const res = await shareLogin(server, sharing.sharingKey, sharing.password);
shouldBeValidUser(res, RouteTestingHelper.getExpectedSharingUser(sharing));
RouteTestingHelper.shouldBeValidUIUser(res, RouteTestingHelper.getExpectedSharingUserForUI(sharing));
});
@@ -92,7 +83,7 @@ describe('SharingRouter', () => {
Config.Sharing.passwordRequired = false;
const sharing = await RouteTestingHelper.createSharing(testUser );
const res = await shareLogin(server, sharing.sharingKey, sharing.password);
shouldBeValidUser(res, RouteTestingHelper.getExpectedSharingUser(sharing));
RouteTestingHelper.shouldBeValidUIUser(res, RouteTestingHelper.getExpectedSharingUserForUI(sharing));
});

View File

@@ -15,28 +15,25 @@ import {DatabaseType} from '../../../../src/common/config/private/PrivateConfig'
import {ProjectPath} from '../../../../src/backend/ProjectPath';
import * as chai from "chai";
import {default as chaiHttp, request} from "chai-http";
import {DBTestHelper} from '../../DBTestHelper';
process.env.NODE_ENV = 'test';
const should = chai.should();
chai.use(chaiHttp);
describe('UserRouter', () => {
const sqlHelper = new DBTestHelper(DatabaseType.sqlite);
const testUser: UserDTO = {
id: 1,
name: 'test',
password: 'test',
role: UserRoles.User,
permissions: null
role: UserRoles.User
};
const {password, ...expectedUser} = testUser;
const tempDir = path.join(__dirname, '../../tmp');
let server: Server;
const setUp = async () => {
await fs.promises.rm(tempDir, {recursive: true, force: true});
Config.Database.type = DatabaseType.sqlite;
Config.Database.dbFolder = tempDir;
ProjectPath.reset();
await sqlHelper.initDB()
server = new Server(false);
await server.onStarted.wait();
@@ -45,18 +42,10 @@ describe('UserRouter', () => {
await SQLConnection.close();
};
const tearDown = async () => {
await ObjectManagers.reset();
await fs.promises.rm(tempDir, {recursive: true, force: true});
await sqlHelper.clearDB()
};
const checkUserResult = (result: any, user: any) => {
result.should.have.status(200);
result.body.should.be.a('object');
should.equal(result.body.error, null);
const {...u} = result.body.result;
u.should.deep.equal(user);
};
const login = async (srv: Server): Promise<any> => {
const result = await (request.execute(srv.Server) as SuperAgentStatic)
@@ -69,7 +58,7 @@ describe('UserRouter', () => {
} as LoginCredential
});
checkUserResult(result, expectedUser);
RouteTestingHelper.shouldBeValidUIUser(result, expectedUser);
return result;
};
@@ -106,7 +95,7 @@ describe('UserRouter', () => {
.get(Config.Server.apiPath + '/user/me')
.set('Cookie', loginRes.res.headers['set-cookie']);
checkUserResult(result, expectedUser);
RouteTestingHelper.shouldBeValidUIUser(result, expectedUser);
});
it('it should not authenticate', async () => {
@@ -134,7 +123,7 @@ describe('UserRouter', () => {
.set('Cookie', loginRes.res.headers['set-cookie']);
// should return with logged in user, not limited sharing one
checkUserResult(result, expectedUser);
RouteTestingHelper.shouldBeValidUIUser(result, expectedUser);
});
@@ -150,7 +139,7 @@ describe('UserRouter', () => {
const result = await request.execute(server.Server)
.get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharing.sharingKey);
checkUserResult(result, RouteTestingHelper.getExpectedSharingUser(sharing));
RouteTestingHelper.shouldBeValidUIUser(result, RouteTestingHelper.getExpectedSharingUserForUI(sharing));
});
it('it should not authenticate with sharing key without password', async () => {
@@ -183,7 +172,7 @@ describe('UserRouter', () => {
} as UserDTO;
checkUserResult(result, expectedGuestUser);
RouteTestingHelper.shouldBeValidUIUser(result, expectedGuestUser);
});
});
});

View File

@@ -78,61 +78,6 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
});
});
describe('authorisePath', () => {
const req = {
session: {
context: {
user: {permissions: null as string[]}
}
},
sessionOptions: {},
query: {},
params: {
path: '/test'
}
};
const authoriseDirPath = AuthenticationMWs.authoriseDirectories('path');
const test = (relativePath: string): Promise<string | number> => {
return new Promise((resolve) => {
req.params.path = path.normalize(relativePath);
authoriseDirPath(req as any, {sendStatus: resolve} as any, () => {
resolve('ok');
});
});
};
it('should catch unauthorized path usage', async () => {
req.session.context.user.permissions = [path.normalize('/sub/subsub')];
expect(await test('/sub/subsub')).to.be.eql('ok');
expect(await test('/test')).to.be.eql(403);
expect(await test('/')).to.be.eql(403);
expect(await test('/sub/test')).to.be.eql(403);
expect(await test('/sub/subsub/test')).to.be.eql(403);
expect(await test('/sub/subsub/test/test2')).to.be.eql(403);
req.session.context.user.permissions = [path.normalize('/sub/subsub'), path.normalize('/sub/subsub2')];
expect(await test('/sub/subsub2')).to.be.eql('ok');
expect(await test('/sub/subsub')).to.be.eql('ok');
expect(await test('/test')).to.be.eql(403);
expect(await test('/')).to.be.eql(403);
expect(await test('/sub/test')).to.be.eql(403);
expect(await test('/sub/subsub/test')).to.be.eql(403);
expect(await test('/sub/subsub2/test')).to.be.eql(403);
req.session.context.user.permissions = [path.normalize('/sub/subsub*')];
expect(await test('/b')).to.be.eql(403);
expect(await test('/sub')).to.be.eql(403);
expect(await test('/sub/subsub2')).to.be.eql(403);
expect(await test('/sub/subsub2/test')).to.be.eql(403);
expect(await test('/sub/subsub')).to.be.eql('ok');
expect(await test('/sub/subsub/test')).to.be.eql('ok');
expect(await test('/sub/subsub/test/two')).to.be.eql('ok');
});
});
describe('inverseAuthenticate', () => {
it('should call next with error on authenticated', (done: (err?: any) => void) => {
@@ -553,10 +498,10 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
describe('authoriseMedia', () => {
const buildReq = (mediaRelPath: string, permissions: string[]) => ({
const buildReq = (mediaRelPath: string) => ({
session: {
context: {
user: {permissions}
user: {role: UserRoles.LimitedGuest}
}
},
params: {
@@ -574,15 +519,8 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
});
});
it('should deny if directory permission fails', async () => {
const req = buildReq('/private/dir/photo.jpg', [path.normalize('/allowed')]);
// No GalleryManager mock needed; should fail before DB check
const result = await run(req);
expect(result).to.eql(403);
});
it('should call next if directory permitted and GalleryManager.authoriseMedia allows', async () => {
const req = buildReq('/allowed/dir/photo.jpg', [path.normalize('/allowed*')]);
it('should call next if GalleryManager.authoriseMedia allows', async () => {
const req = buildReq('/allowed/dir/photo.jpg');
// Mock GalleryManager to allow
(ObjectManagers.getInstance() as any).GalleryManager = {
@@ -594,7 +532,7 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
});
it('should deny if GalleryManager.authoriseMedia denies', async () => {
const req = buildReq('/allowed/dir/photo.jpg', [path.normalize('/allowed*')]);
const req = buildReq('/allowed/dir/photo.jpg');
// Mock GalleryManager to deny
(ObjectManagers.getInstance() as any).GalleryManager = {
@@ -606,7 +544,7 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
});
it('should deny (403) on error thrown by GalleryManager.authoriseMedia', async () => {
const req = buildReq('/allowed/dir/photo.jpg', [path.normalize('/allowed*')]);
const req = buildReq('/allowed/dir/photo.jpg');
(ObjectManagers.getInstance() as any).GalleryManager = {
authoriseMedia: async () => {
@@ -619,6 +557,55 @@ describe('Authentication middleware', (sqlHelper: DBTestHelper) => {
});
});
describe('authoriseMetaFiles', () => {
const buildReq = (metaPath: string) => ({
session: {
context: {
user: {role: UserRoles.LimitedGuest}
}
},
params: {
metaPath: path.normalize(metaPath)
}
});
const run = (req: any) => new Promise((resolve) => {
const res = {sendStatus: (code: number) => resolve(code)} as any;
const next = () => resolve('ok');
const mw = AuthenticationMWs.authoriseMetaFiles('metaPath');
AuthenticationMWs.normalizePathParam('metaPath')(req as any, {} as any, () => {
(mw as any)(req as any, res, next);
});
});
it('should call next if GalleryManager.authoriseMetaFile allows', async () => {
const req = buildReq('/some/dir/notes.md');
(ObjectManagers.getInstance() as any).GalleryManager = {
authoriseMetaFile: async (_session: any, _path: string) => true
} as any;
const result = await run(req);
expect(result).to.eql('ok');
});
it('should deny if GalleryManager.authoriseMetaFile denies', async () => {
const req = buildReq('/some/dir/notes.md');
(ObjectManagers.getInstance() as any).GalleryManager = {
authoriseMetaFile: async (_session: any, _path: string) => false
} as any;
const result = await run(req);
expect(result).to.eql(403);
});
it('should deny (403) on error thrown by GalleryManager.authoriseMetaFile', async () => {
const req = buildReq('/some/dir/notes.md');
(ObjectManagers.getInstance() as any).GalleryManager = {
authoriseMetaFile: async () => { throw new Error('db error'); }
} as any;
const result = await run(req);
expect(result).to.eql(403);
});
});
describe('logout', () => {
it('should call next on logout', (done: (err?: any) => void) => {
const req: any = {

View File

@@ -6,6 +6,7 @@ import {ANDSearchQuery, SearchQueryTypes, TextSearch, TextSearchQueryMatchTypes}
import {UserRoles} from '../../../../src/common/entities/UserDTO';
import {Brackets} from 'typeorm';
import {DBTestHelper} from '../../DBTestHelper';
import {SharingEntity} from '../../../../src/backend/model/database/enitites/SharingEntity';
declare let describe: any;
declare const it: any;
@@ -31,7 +32,6 @@ describe('ObjectManagers', (sqlHelper: DBTestHelper) => {
user.id = 1;
user.name = 'testuser';
user.role = UserRoles.Admin;
user.permissions = ['/test'];
// Create the context
const context = await ObjectManagers.getInstance().buildContext(user);
@@ -214,4 +214,96 @@ describe('ObjectManagers', (sqlHelper: DBTestHelper) => {
expect(context1.user.projectionKey).to.be.eql(context2.user.projectionKey);
});
});
describe('buildAllowListForSharing', () => {
// Reset ObjectManagers before each test
beforeEach(async () => {
await sqlHelper.initDB();
});
afterEach(sqlHelper.clearDB);
it('should return sharing.searchQuery unchanged when creator has no allow/block query', async () => {
const creator = new UserEntity();
creator.id = 10;
creator.name = 'creator1';
creator.role = UserRoles.Admin;
creator.overrideAllowBlockList = true; // use only user queries; none set
const sharing = new SharingEntity();
sharing.creator = creator as any;
sharing.searchQuery = {
type: SearchQueryTypes.directory,
text: '/shared/path',
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch;
const result = ObjectManagers.getInstance().buildAllowListForSharing(sharing);
expect(result).to.be.eql(sharing.searchQuery);
});
it('should AND creator allowQuery with sharing.searchQuery', async () => {
const creator = new UserEntity();
creator.id = 11;
creator.name = 'creator2';
creator.role = UserRoles.Admin;
creator.overrideAllowBlockList = true;
creator.allowQuery = {
type: SearchQueryTypes.directory,
text: '/allowed/by/creator',
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch;
const sharing = new SharingEntity();
sharing.creator = creator as any;
sharing.searchQuery = {
type: SearchQueryTypes.file_name,
text: 'holiday',
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch;
const result = ObjectManagers.getInstance().buildAllowListForSharing(sharing) as ANDSearchQuery;
expect(result.type).to.be.eql(SearchQueryTypes.AND);
expect(result.list).to.have.lengthOf(2);
expect(result.list[0]).to.be.eql(creator.allowQuery);
expect(result.list[1]).to.be.eql(sharing.searchQuery);
});
it('should AND creator (allow AND negated block) with sharing.searchQuery', async () => {
const creator = new UserEntity();
creator.id = 12;
creator.name = 'creator3';
creator.role = UserRoles.Admin;
creator.overrideAllowBlockList = true;
creator.allowQuery = {
type: SearchQueryTypes.directory,
text: '/allowed',
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch;
creator.blockQuery = {
type: SearchQueryTypes.file_name,
text: 'secret',
matchType: TextSearchQueryMatchTypes.exact_match,
negate: false
} as TextSearch;
const sharing = new SharingEntity();
sharing.creator = creator as any;
sharing.searchQuery = {
type: SearchQueryTypes.directory,
text: '/event/path',
matchType: TextSearchQueryMatchTypes.exact_match
} as TextSearch;
const result = ObjectManagers.getInstance().buildAllowListForSharing(sharing) as ANDSearchQuery;
expect(result.type).to.be.eql(SearchQueryTypes.AND);
expect(result.list).to.have.lengthOf(2);
const creatorQuery = result.list[0] as ANDSearchQuery;
expect(creatorQuery.type).to.be.eql(SearchQueryTypes.AND);
expect(creatorQuery.list).to.have.lengthOf(2);
expect(creatorQuery.list[0]).to.be.eql(creator.allowQuery);
expect((creatorQuery.list[1] as TextSearch).negate).to.be.true;
expect(result.list[1]).to.be.eql(sharing.searchQuery);
});
});
});

View File

@@ -1,4 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import {DBTestHelper} from '../../../DBTestHelper';
import {GalleryManager} from '../../../../../src/backend/model/database/GalleryManager';
import {ParentDirectoryDTO} from '../../../../../src/common/entities/DirectoryDTO';
@@ -338,6 +339,130 @@ describe('GalleryManager', (sqlHelper: DBTestHelper) => {
expect(calledArgs[1]).to.equal(true);
});
});
describe('GalleryManager authorisation', () => {
let gm: GalleryManager;
before(async () => {
await sqlHelper.initDB();
await sqlHelper.setUpTestGallery();
await ObjectManagers.getInstance().init();
gm = new GalleryManager();
});
after(async () => {
await sqlHelper.clearDB();
});
describe('authoriseMedia', () => {
it('returns true without projection', async () => {
const session = new SessionContext();
const root = sqlHelper.testGalleyEntities.dir;
const media = sqlHelper.testGalleyEntities.p; // Photo1_*.jpg in root
const mediaPath = path.join(root.path, root.name, media.name);
const res = await gm.authoriseMedia(session, mediaPath);
expect(res).to.equal(true);
});
it('enforces projection: allows only matching media name (like "Photo1")', async () => {
const searchQuery = {
type: SearchQueryTypes.file_name,
text: 'photo1',
matchType: TextSearchQueryMatchTypes.like,
};
const session = await ObjectManagers.getInstance().buildContext({
allowQuery: searchQuery,
overrideAllowBlockList: true,
} as any);
const root = sqlHelper.testGalleyEntities.dir;
const p1 = sqlHelper.testGalleyEntities.p; // Photo1_*.jpg
const p2 = sqlHelper.testGalleyEntities.p2; // Photo2_*.jpg
const p1Path = path.join(root.path, root.name, p1.name);
const p2Path = path.join(root.path, root.name, p2.name);
expect(await gm.authoriseMedia(session, p1Path)).to.equal(true);
expect(await gm.authoriseMedia(session, p2Path)).to.equal(false);
});
it('returns false when file exists but under different directory (directory constraint)', async () => {
const searchQuery = {
type: SearchQueryTypes.file_name,
text: 'photo1',
matchType: TextSearchQueryMatchTypes.like,
};
const session = await ObjectManagers.getInstance().buildContext({
allowQuery: searchQuery,
overrideAllowBlockList: true,
} as any);
const subDir = sqlHelper.testGalleyEntities.subDir; // The Phantom Menace
const p1 = sqlHelper.testGalleyEntities.p; // lives in root
const wrongPath = path.join(subDir.path, subDir.name, p1.name);
expect(await gm.authoriseMedia(session, wrongPath)).to.equal(false);
});
it('returns false for non-existent file under projection', async () => {
const searchQuery = {
type: SearchQueryTypes.file_name,
text: 'photo1',
matchType: TextSearchQueryMatchTypes.like,
};
const session = await ObjectManagers.getInstance().buildContext({
allowQuery: searchQuery,
overrideAllowBlockList: true,
} as any);
const root = sqlHelper.testGalleyEntities.dir;
const nonExist = path.join(root.path, root.name, 'does_not_exist.jpg');
expect(await gm.authoriseMedia(session, nonExist)).to.equal(false);
});
});
describe('authoriseMetaFile', () => {
it('returns true without projection', async () => {
const session = new SessionContext();
const root = sqlHelper.testGalleyEntities.dir;
const metaPath = path.join(root.path, root.name, 'README.md');
expect(await gm.authoriseMetaFile(session, metaPath)).to.equal(true);
});
it('allows directory that contains matching media (projection applied)', async () => {
const searchQuery = {
type: SearchQueryTypes.file_name,
text: 'photo1',
matchType: TextSearchQueryMatchTypes.like,
};
const session = await ObjectManagers.getInstance().buildContext({
allowQuery: searchQuery,
overrideAllowBlockList: true,
} as any);
const root = sqlHelper.testGalleyEntities.dir; // root contains Photo1_*.jpg
const metaPath = path.join(root.path, root.name, 'folder.gpx');
expect(await gm.authoriseMetaFile(session, metaPath)).to.equal(true);
});
it('denies directory that has no matching media under projection', async () => {
const searchQuery = {
type: SearchQueryTypes.file_name,
text: 'photo1',
matchType: TextSearchQueryMatchTypes.like,
};
const session = await ObjectManagers.getInstance().buildContext({
allowQuery: searchQuery,
overrideAllowBlockList: true,
} as any);
const subDir = sqlHelper.testGalleyEntities.subDir; // contains Photo3_*.jpg
const metaPath = path.join(subDir.path, subDir.name, 'info.md');
expect(await gm.authoriseMetaFile(session, metaPath)).to.equal(false);
});
});
});
});

View File

@@ -1576,7 +1576,7 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
(it('should flatter SOME_OF query', () => {
const sm = new SearchManagerTest();
const parser = new SearchQueryParser();
const alphabet = 'abcdefghijklmnopqrstu';
const alphabet = 'abcdefghijklmnopqrs';
const shortestDepth = (q: SearchQueryDTO): number => {

View File

@@ -1,26 +1,8 @@
import {expect} from 'chai';
import {UserDTO, UserDTOUtils} from '../../../src/common/entities/UserDTO';
import {UserRoles} from '../../../src/common/entities/UserDTO';
describe('UserDTO', () => {
it('should check available path', () => {
expect(UserDTOUtils.isDirectoryPathAvailable('/', ['/'])).to.be.equals(true);
expect(UserDTOUtils.isDirectoryPathAvailable('/', ['/subfolder', '/'])).to.be.equals(true);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc', ['/subfolder', '/'])).to.be.equals(false);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc', ['/subfolder', '/*'])).to.be.equals(true);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc', ['/subfolder'])).to.be.equals(false);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc/two', ['/subfolder'])).to.be.equals(false);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc/two', ['/'])).to.be.equals(false);
expect(UserDTOUtils.isDirectoryPathAvailable('/abc/two', ['/*'])).to.be.equals(true);
it('should expose UserRoles enum', () => {
expect(UserRoles.Admin).to.be.a('number');
});
it('should check directory', () => {
expect(UserDTOUtils.isDirectoryAvailable({path: '/', name: 'abc'} as any, ['/*'])).to.be.equals(true);
expect(UserDTOUtils.isDirectoryAvailable({path: '/', name: 'abc'} as any, ['/'])).to.be.equals(false);
expect(UserDTOUtils.isDirectoryAvailable({path: '.\\', name: '.'} as any, ['/'])).to.be.equals(true);
expect(UserDTOUtils.isDirectoryAvailable({path: '/', name: 'abc'} as any, ['/*', '/asdad'])).to.be.equals(true);
});
});