Skip to content

Commit 4e9b2a6

Browse files
[DURACOM-470] add handling for non latin chars in custom url, fix missing UI error in edit mode
1 parent 06e37b7 commit 4e9b2a6

File tree

10 files changed

+127
-20
lines changed

10 files changed

+127
-20
lines changed

src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ import { map } from 'rxjs/operators';
88

99
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
1010
import { ItemDataService } from '../data/item-data.service';
11-
import { getDSORoute } from '../router/utils/dso-route.utils';
11+
import {
12+
CUSTOM_URL_VALID_PATTERN,
13+
getDSORoute,
14+
getItemPageRoute,
15+
} from '../router/utils/dso-route.utils';
1216
import { DSpaceObject } from '../shared/dspace-object.model';
1317
import { FollowLinkConfig } from '../shared/follow-link-config.model';
18+
import { Item } from '../shared/item.model';
1419
import {
1520
getFirstCompletedRemoteData,
1621
getRemoteDataPayload,
@@ -63,7 +68,16 @@ export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state:
6368
getRemoteDataPayload(),
6469
map((object: DSpaceObject) => {
6570
if (hasValue(object)) {
66-
return { provider: breadcrumbService, key: object, url: getDSORoute(object) };
71+
// For items, fall back to UUID-based route if the custom URL contains non-latin characters
72+
let url: string;
73+
if (object instanceof Item && object.hasMetadata('dspace.customurl')) {
74+
const customUrl = object.firstMetadataValue('dspace.customurl');
75+
const ignoreCustomUrl = !CUSTOM_URL_VALID_PATTERN.test(customUrl);
76+
url = getItemPageRoute(object as Item, ignoreCustomUrl);
77+
} else {
78+
url = getDSORoute(object);
79+
}
80+
return { provider: breadcrumbService, key: object, url };
6781
} else {
6882
return undefined;
6983
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { PathableObjectError } from './response-state.model';
2+
13
export class RequestError extends Error {
24
statusCode: number;
35
statusText: string;
6+
errors?: PathableObjectError[];
47
}

src/app/core/data/request.actions.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Action } from '@ngrx/store';
44
import { type } from '../ngrx/type';
55
import { HALLink } from '../shared/hal-link.model';
66
import { UnCacheableObject } from '../shared/uncacheable-object.model';
7+
import { PathableObjectError } from './response-state.model';
78
import { RestRequest } from './rest-request.model';
89

910
/**
@@ -103,7 +104,8 @@ export class RequestErrorAction extends RequestUpdateAction {
103104
uuid: string,
104105
timeCompleted: number,
105106
statusCode: number,
106-
errorMessage: string
107+
errorMessage: string,
108+
errors?: PathableObjectError[]
107109
};
108110

109111
/**
@@ -115,14 +117,17 @@ export class RequestErrorAction extends RequestUpdateAction {
115117
* the statusCode of the response
116118
* @param errorMessage
117119
* the error message in the response
120+
* @param errors
121+
* the list of pathable errors
118122
*/
119-
constructor(uuid: string, statusCode: number, errorMessage: string) {
123+
constructor(uuid: string, statusCode: number, errorMessage: string, errors?: PathableObjectError[]) {
120124
super();
121125
this.payload = {
122126
uuid,
123127
timeCompleted: new Date().getTime(),
124128
statusCode,
125129
errorMessage,
130+
errors,
126131
};
127132
}
128133
}

src/app/core/data/request.effects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export class RequestEffects {
6868
catchError((error: unknown) => {
6969
if (error instanceof RequestError) {
7070
// if it's an error returned by the server, complete the request
71-
return [new RequestErrorAction(request.uuid, error.statusCode, error.message)];
71+
return [new RequestErrorAction(request.uuid, error.statusCode, error.message, error.errors)];
7272
} else {
7373
// if it's a client side error, throw it
7474
throw error;

src/app/core/data/request.reducer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ function completeFailedRequest(storeState: RequestState, action: RequestErrorAct
151151
statusCode: action.payload.statusCode,
152152
payloadLink: null,
153153
errorMessage: action.payload.errorMessage,
154+
errors: action.payload.errors,
154155
},
155156
lastUpdated: action.lastUpdated,
156157
}),

src/app/core/dspace-rest/dspace-rest.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export class DspaceRestService {
159159

160160
error.statusCode = err.status;
161161
error.statusText = err.statusText;
162+
error.errors = err?.error || null;
162163

163164
return error;
164165
} else {

src/app/core/router/utils/dso-route.utils.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {
1616
getItemModuleRoute,
1717
} from '../core-routing-paths';
1818

19+
/**
20+
* Regex to validate that a custom URL contains only safe latin-compatible characters.
21+
* If the custom URL does not match this pattern, routing falls back to the item UUID.
22+
*/
23+
export const CUSTOM_URL_VALID_PATTERN = /^[.a-zA-Z0-9\-_]+$/;
24+
1925
export function getCollectionPageRoute(collectionId: string) {
2026
return new URLCombiner(getCollectionModuleRoute(), collectionId).toString();
2127
}
@@ -26,8 +32,10 @@ export function getCommunityPageRoute(communityId: string) {
2632

2733
/**
2834
* Get the route to an item's page
29-
* Depending on the item's entity type, the route will either start with /items or /entities
30-
* @param item The item to retrieve the route for
35+
* Depending on the item's entity type, the route will either start with /items or /entities.
36+
*
37+
* @param item The item to retrieve the route for
38+
* @param ignoreCustomUrl When true, always use the UUID even if a valid custom URL exists
3139
*/
3240
export function getItemPageRoute(item: Item, ignoreCustomUrl = false) {
3341
const type = item.firstMetadataValue('dspace.entity.type');

src/app/item-page/item-page.resolver.spec.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { HardRedirectService } from '@dspace/core/services/hard-redirect.service
88
import { DSpaceObject } from '@dspace/core/shared/dspace-object.model';
99
import { MetadataValueFilter } from '@dspace/core/shared/metadata.models';
1010
import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub';
11+
import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub';
1112
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
1213
import { first } from 'rxjs/operators';
1314

@@ -35,6 +36,8 @@ describe('itemPageResolver', () => {
3536
let authService: AuthServiceStub;
3637
let platformId: any;
3738
let hardRedirectService: any;
39+
let notificationsService: NotificationsServiceStub;
40+
let translateService: any;
3841

3942
const uuid = '1234-65487-12354-1235';
4043
let item: DSpaceObject;
@@ -46,6 +49,8 @@ describe('itemPageResolver', () => {
4649
hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
4750
redirect: {},
4851
});
52+
notificationsService = new NotificationsServiceStub();
53+
translateService = { instant: (key: string) => key } as any;
4954
item = Object.assign(new DSpaceObject(), {
5055
uuid: uuid,
5156
firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string {
@@ -74,6 +79,8 @@ describe('itemPageResolver', () => {
7479
authService,
7580
platformId,
7681
hardRedirectService,
82+
notificationsService,
83+
translateService,
7784
).pipe(first())
7885
.subscribe(
7986
() => {
@@ -83,7 +90,7 @@ describe('itemPageResolver', () => {
8390
);
8491
});
8592

86-
it('should not redirect if were already on the correct route', (done) => {
93+
it('should not redirect if we\'re already on the correct route', (done) => {
8794
spyOn(item, 'firstMetadataValue').and.returnValue(entityType);
8895
spyOn(router, 'navigateByUrl').and.callThrough();
8996

@@ -96,6 +103,8 @@ describe('itemPageResolver', () => {
96103
authService,
97104
platformId,
98105
hardRedirectService,
106+
notificationsService,
107+
translateService,
99108
).pipe(first())
100109
.subscribe(
101110
() => {
@@ -129,6 +138,8 @@ describe('itemPageResolver', () => {
129138
let authService: AuthServiceStub;
130139
let platformId: any;
131140
let hardRedirectService: any;
141+
let notificationsService: NotificationsServiceStub;
142+
let translateService: any;
132143

133144
const uuid = '1234-65487-12354-1235';
134145
let item: DSpaceObject;
@@ -139,6 +150,8 @@ describe('itemPageResolver', () => {
139150
hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
140151
redirect: {},
141152
});
153+
notificationsService = new NotificationsServiceStub();
154+
translateService = { instant: (key: string) => key } as any;
142155
item = Object.assign(new DSpaceObject(), {
143156
uuid: uuid,
144157
id: uuid,
@@ -168,7 +181,7 @@ describe('itemPageResolver', () => {
168181
const route = { params: { id: uuid } } as any;
169182
const state = { url: `/entities/person/${uuid}` } as any;
170183

171-
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService)
184+
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService, notificationsService, translateService)
172185
.pipe(first())
173186
.subscribe((rd: any) => {
174187
const expectedUrl = `/entities/person/${customUrl}`;
@@ -183,7 +196,7 @@ describe('itemPageResolver', () => {
183196
const route = { params: { id: customUrl } } as any;
184197
const state = { url: `/entities/person/${customUrl}` } as any;
185198

186-
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService)
199+
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService, notificationsService, translateService)
187200
.pipe(first())
188201
.subscribe((rd: any) => {
189202
expect(router.navigateByUrl).not.toHaveBeenCalled();
@@ -197,7 +210,7 @@ describe('itemPageResolver', () => {
197210
const route = { params: { id: customUrl } } as any;
198211
const state = { url: `/entities/person/${customUrl}/edit` } as any;
199212

200-
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService)
213+
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService, notificationsService, translateService)
201214
.pipe(first())
202215
.subscribe(() => {
203216
expect(router.navigateByUrl).toHaveBeenCalledWith(`/entities/person/${uuid}/edit`);
@@ -211,13 +224,48 @@ describe('itemPageResolver', () => {
211224
const route = { params: { id: customUrl } } as any;
212225
const state = { url: `/entities/person/${customUrl}/full` } as any;
213226

214-
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService)
227+
resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService, notificationsService, translateService)
215228
.pipe(first())
216229
.subscribe(() => {
217230
expect(router.navigateByUrl).not.toHaveBeenCalled();;
218231
done();
219232
});
220233
});
234+
235+
it('should fall back to UUID and show a warning notification when dspace.customurl contains invalid characters', (done) => {
236+
const invalidCustomUrl = 'café-测试';
237+
const itemWithInvalidUrl = Object.assign(new DSpaceObject(), {
238+
uuid: uuid,
239+
id: uuid,
240+
firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string {
241+
return _keyOrKeys === 'dspace.entity.type' ? 'person' : invalidCustomUrl;
242+
},
243+
hasMetadata(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): boolean {
244+
return true;
245+
},
246+
metadata: { 'dspace.customurl': invalidCustomUrl },
247+
});
248+
const invalidItemService = {
249+
findByIdOrCustomUrl: (_id: string) => createSuccessfulRemoteDataObject$(itemWithInvalidUrl),
250+
};
251+
notificationsService = new NotificationsServiceStub();
252+
translateService = { instant: (key: string) => key } as any;
253+
254+
spyOn(router, 'navigateByUrl').and.callThrough();
255+
spyOn(notificationsService, 'warning').and.callThrough();
256+
257+
const encodedInvalidUrl = encodeURIComponent(invalidCustomUrl);
258+
const route = { params: { id: invalidCustomUrl } } as any;
259+
const state = { url: `/entities/person/${encodedInvalidUrl}` } as any;
260+
261+
resolver(route, state, router, invalidItemService, store, authService, platformId, hardRedirectService, notificationsService, translateService)
262+
.pipe(first())
263+
.subscribe(() => {
264+
expect(router.navigateByUrl).toHaveBeenCalledWith(`/entities/person/${uuid}`);
265+
expect(notificationsService.warning).toHaveBeenCalled();
266+
done();
267+
});
268+
});
221269
});
222270

223271
});

src/app/item-page/item-page.resolver.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ import {
1212
import { AuthService } from '@dspace/core/auth/auth.service';
1313
import { ItemDataService } from '@dspace/core/data/item-data.service';
1414
import { RemoteData } from '@dspace/core/data/remote-data';
15+
import { NotificationOptions } from '@dspace/core/notification-system/models/notification-options.model';
16+
import { NotificationsService } from '@dspace/core/notification-system/notifications.service';
1517
import { ResolvedAction } from '@dspace/core/resolving/resolver.actions';
16-
import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils';
18+
import {
19+
CUSTOM_URL_VALID_PATTERN,
20+
getItemPageRoute,
21+
} from '@dspace/core/router/utils/dso-route.utils';
1722
import { HardRedirectService } from '@dspace/core/services/hard-redirect.service';
1823
import {
1924
redirectOn4xx,
@@ -26,6 +31,7 @@ import {
2631
import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators';
2732
import { hasValue } from '@dspace/shared/utils/empty.util';
2833
import { Store } from '@ngrx/store';
34+
import { TranslateService } from '@ngx-translate/core';
2935
import { Observable } from 'rxjs';
3036
import { map } from 'rxjs/operators';
3137

@@ -51,6 +57,8 @@ export const itemPageResolver: ResolveFn<RemoteData<Item>> = (
5157
authService: AuthService = inject(AuthService),
5258
platformId: any = inject(PLATFORM_ID),
5359
hardRedirectService: HardRedirectService = inject(HardRedirectService),
60+
notificationsService: NotificationsService = inject(NotificationsService),
61+
translateService: TranslateService = inject(TranslateService),
5462
): Observable<RemoteData<Item>> => {
5563
const itemRD$ = itemService.findByIdOrCustomUrl(
5664
route.params.id,
@@ -67,23 +75,36 @@ export const itemPageResolver: ResolveFn<RemoteData<Item>> = (
6775
store.dispatch(new ResolvedAction(state.url, itemRD.payload));
6876
});
6977

78+
7079
return itemRD$.pipe(
7180
map((rd: RemoteData<Item>) => {
7281
if (rd.hasSucceeded && hasValue(rd.payload)) {
73-
let itemRoute;
82+
let itemRoute: string;
7483
if (hasValue(rd.payload.metadata) && rd.payload.hasMetadata('dspace.customurl')) {
7584
const customUrl = rd.payload.firstMetadataValue('dspace.customurl');
76-
const isSubPath = !(state.url.endsWith(customUrl) || state.url.endsWith(rd.payload.id) || state.url.endsWith('/full'));
77-
itemRoute = isSubPath ? state.url : router.parseUrl(getItemPageRoute(rd.payload)).toString();
85+
const isValidCustomUrl = CUSTOM_URL_VALID_PATTERN.test(customUrl);
86+
const decodedStateUrl = decodeURIComponent(state.url);
87+
const isSubPath = !(decodedStateUrl.endsWith(customUrl) || decodedStateUrl.endsWith(rd.payload.id) || decodedStateUrl.endsWith('/full'));
88+
itemRoute = (isSubPath || !isValidCustomUrl) ? state.url : router.parseUrl(getItemPageRoute(rd.payload)).toString();
7889
let newUrl: string;
79-
if (route.params.id !== customUrl && !isSubPath) {
80-
newUrl = itemRoute.replace(route.params.id,rd.payload.firstMetadataValue('dspace.customurl'));
81-
} else if (isSubPath && route.params.id === customUrl) {
90+
if (route.params.id !== customUrl && !isSubPath && isValidCustomUrl) {
91+
newUrl = itemRoute.replace(route.params.id, rd.payload.firstMetadataValue('dspace.customurl'));
92+
} else if ((isSubPath || !isValidCustomUrl) && route.params.id === customUrl) {
8293
// In case of a sub path, we need to ensure we navigate to the edit page of the item ID, not the custom URL
8394
const itemId = rd.payload.uuid;
84-
newUrl = itemRoute.replace(rd.payload.firstMetadataValue('dspace.customurl'), itemId);
95+
newUrl = decodeURIComponent(itemRoute).replace(customUrl, itemId);
96+
if (!isValidCustomUrl && !isSubPath) {
97+
// Notify the user that custom url won't be used as it is malformed
98+
const notificationOptions = new NotificationOptions(-1, true);
99+
notificationsService.warning(
100+
translateService.instant('item-page.resolver.invalid-custom-url.title'),
101+
translateService.instant('item-page.resolver.invalid-custom-url.message'),
102+
notificationOptions,
103+
);
104+
}
85105
}
86106

107+
87108
if (hasValue(newUrl)) {
88109
router.navigateByUrl(newUrl);
89110
}

src/assets/i18n/en.json5

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3138,6 +3138,10 @@
31383138

31393139
"item.page.filesection.license.bundle": "License bundle",
31403140

3141+
"item-page.resolver.invalid-custom-url.title": "Invalid custom URL",
3142+
3143+
"item-page.resolver.invalid-custom-url.message": "The custom URL of this item contains unsupported characters. The item is being displayed using its UUID.",
3144+
31413145
"item.page.return": "Back",
31423146

31433147
"item.page.version.create": "Create new version",
@@ -5765,6 +5769,8 @@
57655769

57665770
"submission.sections.general.deposit_error_notice": "There was an issue when submitting the item, please try again later.",
57675771

5772+
"submission.sections.general.invalid_state_error": "Cannot save current changes, mandatory fields are missing. Please resolve problems and save again later.",
5773+
57685774
"submission.sections.general.deposit_success_notice": "Submission deposited successfully.",
57695775

57705776
"submission.sections.general.discard_error_notice": "There was an issue when discarding the item, please try again later.",

0 commit comments

Comments
 (0)