Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions src/backend/middlewares/SharingMWs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ export class SharingMWs {
return next();
}
const sharingKey = req.params[QueryParams.gallery.sharingKey_params];

req.resultPipe =
{sharingKey: (await ObjectManagers.getInstance().SharingManager.findOne(sharingKey)).sharingKey} as SharingDTOKey;
const sharing = await ObjectManagers.getInstance().SharingManager.findOne(sharingKey);
req.resultPipe = {
sharingKey: sharing.sharingKey,
passwordProtected: !!(sharing.password || Config.Sharing.passwordRequired),
} as SharingDTOKey;
return next();
} catch (err) {
return next(
Expand Down
25 changes: 20 additions & 5 deletions src/backend/model/database/SearchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,15 +481,16 @@ export class SearchManager {

public async prepareAndBuildWhereQuery(
queryIN: SearchQueryDTO, directoryOnly = false,
aliases: { [key: string]: string } = {}): Promise<Brackets> {
aliases: { [key: string]: string } = {},
recursiveDir = false): Promise<Brackets> {
let query = await this.prepareQuery(queryIN);
if (directoryOnly) {
query = this.filterDirectoryQuery(query);
if (query === null) {
return null;
}
}
return this.buildWhereQuery(query, directoryOnly, aliases);
return this.buildWhereQuery(query, directoryOnly, aliases, recursiveDir);
}

public async prepareQuery(queryIN: SearchQueryDTO): Promise<SearchQueryDTO> {
Expand All @@ -509,22 +510,23 @@ export class SearchManager {
public buildWhereQuery(
query: SearchQueryDTO,
directoryOnly = false,
aliases: { [key: string]: string } = {}
aliases: { [key: string]: string } = {},
recursiveDir = false
): Brackets {
const queryId = (query as SearchQueryDTOWithID).queryId;
switch (query.type) {
case SearchQueryTypes.AND:
return new Brackets((q): unknown => {
(query as ANDSearchQuery).list.forEach((sq) => {
q.andWhere(this.buildWhereQuery(sq, directoryOnly));
q.andWhere(this.buildWhereQuery(sq, directoryOnly, aliases, recursiveDir));
}
);
return q;
});
case SearchQueryTypes.OR:
return new Brackets((q): unknown => {
(query as ANDSearchQuery).list.forEach((sq) => {
q.orWhere(this.buildWhereQuery(sq, directoryOnly));
q.orWhere(this.buildWhereQuery(sq, directoryOnly, aliases, recursiveDir));
}
);
return q;
Expand Down Expand Up @@ -968,6 +970,19 @@ export class SearchManager {
return dq;
})
);
// When building projection queries for sharing, also allow subdirectories recursively
if (
recursiveDir &&
query.type === SearchQueryTypes.directory &&
(query as TextSearch).matchType === TextSearchQueryMatchTypes.exact_match
) {
const normalizedDirPath = dirPathStr.endsWith('/') ? dirPathStr : dirPathStr + '/';
textParam['subDirPath' + queryId] = normalizedDirPath + '%';
q[whereFN](
`${alias}.path ${LIKE} :subDirPath${queryId} COLLATE ` + SQL_COLLATE,
textParam
);
}
}

if (
Expand Down
4 changes: 2 additions & 2 deletions src/backend/model/database/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export class SessionManager {

if (finalQuery) {
// Build the Brackets-based query
context.projectionQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery);
context.projectionQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery, false, {}, true);
context.hasDirectoryProjection = ObjectManagers.getInstance().SearchManager.hasDirectoryQuery(finalQuery);
if (context.hasDirectoryProjection) {
context.projectionQueryForSubDir = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery, true, {directory: 'directories'});
context.projectionQueryForSubDir = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(finalQuery, true, {directory: 'directories'}, true);
}
context.user.projectionKey = this.createProjectionKey(finalQuery);
if (SearchQueryUtils.isQueryEmpty(finalQuery)) {
Expand Down
2 changes: 1 addition & 1 deletion src/backend/routes/GalleryRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class GalleryRouter {
[Config.Server.apiPath + '/gallery/content/:directory(*)', Config.Server.apiPath + '/gallery/', Config.Server.apiPath + '/gallery//'],
// common part
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.Guest), //sharing user can only go through search. They can't just wander through the whole gallery
AuthenticationMWs.authorise(UserRoles.LimitedGuest), // projectionQuery on sharing sessions already limits visible content
AuthenticationMWs.normalizePathParam('directory'),
VersionMWs.injectGalleryVersion,

Expand Down
2 changes: 1 addition & 1 deletion src/backend/routes/SharingRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class SharingRouter {
// its a public path
SharingMWs.getSharingKey,
ServerTimingMWs.addServerTiming,
RenderingMWs.renderSharing
RenderingMWs.renderResult
);
}

Expand Down
1 change: 1 addition & 0 deletions src/common/entities/SharingDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {SearchQueryDTO} from './SearchQueryDTO';

export interface SharingDTOKey {
sharingKey: string;
passwordProtected?: boolean;
}

export interface BaseSharingDTO extends SharingDTOKey {
Expand Down
3 changes: 1 addition & 2 deletions src/frontend/app/model/navigation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {IsActiveMatchOptions, Router} from '@angular/router';
import {ShareService} from '../ui/gallery/share.service';
import {Config} from '../../../common/config/public/Config';
import {NavigationLinkTypes} from '../../../common/config/public/ClientConfig';
import {firstValueFrom} from 'rxjs';

@Injectable()
export class NavigationService {
Expand All @@ -31,7 +30,7 @@ export class NavigationService {
public async toLogin(): Promise<boolean> {
await this.shareService.wait();
if (this.shareService.isSharing()) {
if ((await firstValueFrom(this.shareService.currentSharing)).passwordProtected === true) {
if (this.shareService.sharingPasswordProtected === true) {
return this.router.navigate(['shareLogin'], {
queryParams: {sk: this.shareService.getSharingKey()},
});
Expand Down
15 changes: 11 additions & 4 deletions src/frontend/app/model/network/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare module ServerInject {
@Injectable({providedIn: 'root'})
export class AuthenticationService {
public readonly user: BehaviorSubject<UserDTO>;
private sessionUserPromise: Promise<void> | null = null;

constructor(
private userService: UserService,
Expand All @@ -39,7 +40,8 @@ export class AuthenticationService {
) {
this.user.next(ServerInject.user);
}
this.getSessionUser().catch(console.error);
this.sessionUserPromise = this.getSessionUser();
this.sessionUserPromise.catch(console.error);
} else {
if (Config.Users.authenticationRequired === false) {
this.user.next({
Expand Down Expand Up @@ -99,14 +101,19 @@ export class AuthenticationService {
public async logout(): Promise<void> {
await this.userService.logout();
this.user.next(null);
// even on logout try to get sharing user if it's a sharing
await this.shareService.wait();
if(this.shareService.isSharing()){
// Restore a non-password-protected sharing session after logout.
// Skip for password-protected shares — getSessionUser always returns 401 until re-auth.
if (this.shareService.isSharing() && this.shareService.sharingPasswordProtected !== true) {
await this.getSessionUser();
}
}

private async getSessionUser(): Promise<void> {
public waitForSessionUser(): Promise<void> {
return this.sessionUserPromise ?? Promise.resolve();
}

public async getSessionUser(): Promise<void> {
try {
this.user.next(await this.userService.getSessionUser());
} catch (error) {
Expand Down
30 changes: 22 additions & 8 deletions src/frontend/app/model/network/helper/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,37 @@ import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot} from '@angular/router';
import {AuthenticationService} from '../authentication.service';
import {NavigationService} from '../../navigation.service';
import {ShareService} from '../../../ui/gallery/share.service';

@Injectable({providedIn: 'root'})
export class AuthGuard implements CanActivate{
export class AuthGuard implements CanActivate {
constructor(
private authenticationService: AuthenticationService,
private navigationService: NavigationService
private authenticationService: AuthenticationService,
private navigationService: NavigationService,
private shareService: ShareService,
) {
}

canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean {
if (this.authenticationService.isAuthenticated() === true) {
async canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Promise<boolean> {
// Wait for session cookie restoration (async HTTP call started in constructor)
await this.authenticationService.waitForSessionUser();

if (this.authenticationService.isAuthenticated()) {
return true;
}

// For no-password shares, try backend auto-authentication via the sharing key
await this.shareService.wait();
if (this.shareService.isSharing() && this.shareService.sharingPasswordProtected === false) {
await this.authenticationService.getSessionUser();
if (this.authenticationService.isAuthenticated()) {
return true;
}
}

this.navigationService.toLogin().catch(console.error);
return false;
}
Expand Down
9 changes: 7 additions & 2 deletions src/frontend/app/model/network/helper/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ export class ErrorInterceptor implements HttpInterceptor {
return next.handle(request).pipe(
catchError((err) => {
if (err.status === 401) {
// auto logout if 401 response returned from server
this.authenticationService.logout();
if (this.authenticationService.user.value !== null) {
// Logged-in user got a 401 — log them out
this.authenticationService.logout();
} else {
// Already unauthenticated — just navigate to login to avoid a logout→getSessionUser→401 loop
this.navigationService.toLogin();
}
}
if (err.status === 500 && err.error.error.code === ErrorCodes.INTERNAL) {
// Unknown server error
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/app/ui/gallery/gallery.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ class MockContentService {
}

class MockAuthenticationService {
user = new BehaviorSubject(null); // Add this line
user = new BehaviorSubject(null);
isAuthenticated = jasmine.createSpy('isAuthenticated').and.returnValue(true);
canSearch = jasmine.createSpy('canSearch').and.returnValue(true);
isAuthorized = jasmine.createSpy('isAuthorized').and.returnValue(true);
logout = jasmine.createSpy('logout'); // Also add logout method if needed
logout = jasmine.createSpy('logout');
waitForSessionUser = jasmine.createSpy('waitForSessionUser').and.returnValue(Promise.resolve());
}

class MockShareService {
Expand Down
19 changes: 16 additions & 3 deletions src/frontend/app/ui/gallery/gallery.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {PhotoFilterPipe} from '../../pipes/PhotoFilterPipe';
import {MediaButtonModalComponent} from './grid/photo/media-button-modal/media-button-modal.component';
import {ContentWrapperWithError} from '../../../../common/entities/ContentWrapper';
import {SearchQueryUtils} from '../../../../common/SearchQueryUtils';
import {SearchQueryTypes, TextSearch, TextSearchQueryMatchTypes} from '../../../../common/entities/SearchQueryDTO';
import {UploaderService} from './uploader/uploader.service';
import {GalleryService} from './gallery.service';
import {UploaderComponent} from './uploader/uploader.gallery.component';
Expand Down Expand Up @@ -161,6 +162,7 @@ export class GalleryComponent implements OnInit, OnDestroy {

async ngOnInit(): Promise<boolean> {
await this.shareService.wait();
await this.authService.waitForSessionUser();
if (!this.authService.isAuthenticated()) {
return this.navigation.toLogin();
}
Expand Down Expand Up @@ -234,9 +236,20 @@ export class GalleryComponent implements OnInit, OnDestroy {
const qParams: { [key: string]: any } = {};
qParams[QueryParams.gallery.sharingKey_query] =
this.shareService.getSharingKey();
this.router
.navigate(['/search', JSON.stringify(sharing.searchQuery)], {queryParams: qParams})
.catch(console.error);
// For directory shares, use the gallery directory view so subfolders are navigable.
// For other query types (date, person, etc.) fall back to the search view.
if (
sharing.searchQuery?.type === SearchQueryTypes.directory &&
(sharing.searchQuery as TextSearch).matchType === TextSearchQueryMatchTypes.exact_match
) {
this.router
.navigate(['gallery', (sharing.searchQuery as TextSearch).value], {queryParams: qParams})
.catch(console.error);
} else {
this.router
.navigate(['/search', JSON.stringify(sharing.searchQuery)], {queryParams: qParams})
.catch(console.error);
}
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { StringifySortingMethod } from '../../../pipes/StringifySortingMethod';
import { StringifySearchQuery } from '../../../pipes/StringifySearchQuery';
import { StringifyGridSize } from '../../../pipes/StringifyGridSize';
import {ContentWrapperWithError} from '../../../../../common/entities/ContentWrapper';
import {ShareService} from '../share.service';

@Component({
selector: 'app-gallery-navbar',
Expand Down Expand Up @@ -79,7 +80,8 @@ export class GalleryNavigatorComponent {
public sortingService: GallerySortingService,
public navigatorService: GalleryNavigatorService,
private router: Router,
public sanitizer: DomSanitizer
public sanitizer: DomSanitizer,
private shareService: ShareService,
) {
this.sortingByTypes = Utils.enumToArray(SortByTypes);
// can't group by random
Expand Down Expand Up @@ -130,32 +132,32 @@ export class GalleryNavigatorComponent {

const user = this.authService.user.value;
const arr: NavigatorPath[] = [];
// For sharing users, allow navigation within the shared subtree only
const shareRootPath = user.role <= UserRoles.LimitedGuest ? this.getShareRootPath() : null;

// create root link
if (dirs.length === 0) {
arr.push({name: this.RootFolderName, route: null});
} else {
arr.push({
name: this.RootFolderName,
route: user.role > UserRoles.LimitedGuest // it's basically a sharing. they should not just navigate wherever
? '/'
: null,
route: user.role > UserRoles.LimitedGuest ? '/' : null,
});
}

// create rest navigation
dirs.forEach((name, index) => {
const route = dirs.slice(0, index + 1).join('/');
if (dirs.length - 1 === index) {
arr.push({name, route: null});
arr.push({name, route: null}); // current directory is never a link
} else {
arr.push({
name,
route: user.role > UserRoles.LimitedGuest // it's basically a sharing. they should not just navigate wherever
? route
: null,
});

let linkRoute: string | null = null;
if (user.role > UserRoles.LimitedGuest) {
linkRoute = route;
} else if (shareRootPath && (route === shareRootPath || route.startsWith(shareRootPath + '/'))) {
linkRoute = route;
}
arr.push({name, route: linkRoute});
}
});

Expand Down Expand Up @@ -336,6 +338,18 @@ export class GalleryNavigatorComponent {
}

protected readonly GroupByTypes = GroupByTypes;

private getShareRootPath(): string | null {
const sharing = this.shareService.sharingSubject.value;
if (!sharing?.searchQuery) {
return null;
}
const sq = sharing.searchQuery as TextSearch;
if (sq.type !== SearchQueryTypes.directory || sq.matchType !== TextSearchQueryMatchTypes.exact_match) {
return null;
}
return sq.value.replace(/^\.\//, '').replace(/\\/g, '/');
}
}

interface NavigatorPath {
Expand Down
Loading
Loading