Skip to content

Commit b71e9d4

Browse files
Merged dspace-cris-2025_02_x into task/dspace-cris-2025_02_x/DSC-2860
2 parents da1123c + 7cfcd2c commit b71e9d4

29 files changed

Lines changed: 1079 additions & 93 deletions

cypress/e2e/homepage-statistics.cy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ describe('Site Statistics Page', () => {
1212
cy.location('pathname').should('eq', '/statistics');
1313
});
1414

15-
it('should pass accessibility tests', () => {
15+
// Skipped: this test depends on the external backend image (seeded demo entity + Solr
16+
// statistics indexing). The "Most Viewed" table label loads asynchronously from that
17+
// backend and intermittently stays empty in CI, which we cannot fix from the frontend.
18+
it.skip('should pass accessibility tests', () => {
1619
// generate 2 view events on an Item's page
1720
cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
1821
cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item');
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
2+
import { ObjectCacheService } from '../cache/object-cache.service';
3+
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
4+
import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service';
5+
import { RestRequest } from './rest-request.model';
6+
import { RestRequestMethod } from './rest-request-method';
7+
8+
class TestService extends DspaceRestResponseParsingService {
9+
constructor(protected objectCache: ObjectCacheService) {
10+
super(objectCache);
11+
}
12+
13+
public ensureSelfLinkForTest(request: RestRequest, response: RawRestResponse): RawRestResponse {
14+
return this.ensureSelfLink(request, response);
15+
}
16+
}
17+
18+
describe('DspaceRestResponseParsingService', () => {
19+
let service: TestService;
20+
21+
beforeEach(() => {
22+
service = new TestService(getMockObjectCacheService());
23+
});
24+
25+
describe('ensureSelfLink', () => {
26+
let warnSpy: jasmine.Spy;
27+
28+
beforeEach(() => {
29+
warnSpy = spyOn(console, 'warn');
30+
});
31+
32+
it('does not replace self link when only query params differ', () => {
33+
const request = {
34+
uuid: 'request-id',
35+
href: 'https://rest.test/server/api/core/items/f639b124-1234-1234-1234-abcdef123456?projection=preventMetadataSecurity',
36+
method: RestRequestMethod.GET,
37+
} as RestRequest;
38+
const response: RawRestResponse = {
39+
payload: {
40+
_links: {
41+
self: {
42+
href: 'https://rest.test/server/api/core/items/f639b124-1234-1234-1234-abcdef123456',
43+
},
44+
},
45+
},
46+
statusCode: 200,
47+
statusText: 'OK',
48+
};
49+
50+
const result = service.ensureSelfLinkForTest(request, response);
51+
52+
expect(result.payload._links.self.href).toBe('https://rest.test/server/api/core/items/f639b124-1234-1234-1234-abcdef123456');
53+
expect(warnSpy).not.toHaveBeenCalled();
54+
});
55+
56+
it('replaces self link when path differs', () => {
57+
const request = {
58+
uuid: 'request-id',
59+
href: 'https://rest.test/server/api/core/items/f639b124-1234-1234-1234-abcdef123456',
60+
method: RestRequestMethod.GET,
61+
} as RestRequest;
62+
const response: RawRestResponse = {
63+
payload: {
64+
_links: {
65+
self: {
66+
href: 'https://rest.test/server/api/core/items/some-other-id',
67+
},
68+
},
69+
},
70+
statusCode: 200,
71+
statusText: 'OK',
72+
};
73+
74+
const result = service.ensureSelfLinkForTest(request, response);
75+
76+
expect(result.payload._links.self.href).toBe('https://rest.test/server/api/core/items/f639b124-1234-1234-1234-abcdef123456');
77+
expect(warnSpy).toHaveBeenCalled();
78+
});
79+
});
80+
});
81+

src/app/core/data/dspace-rest-response-parsing.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ export function isRestPaginatedList(halObj: any): boolean {
6060
* @param url the url to split
6161
*/
6262
const splitUrlInParts = (url: string): string[] => {
63-
return url.split('?')
64-
.map((part) => part.split('&'))
65-
.reduce((combined, current) => [...combined, ...current]);
63+
// Compare link structure only, ignoring query params and hash fragments.
64+
const normalizedUrl = url.split('?')[0].split('#')[0];
65+
return normalizedUrl.split('/');
6666
};
6767

6868
@Injectable({ providedIn: 'root' })

src/app/core/feedback/feedback-data.service.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
import { HttpHeaders } from '@angular/common/http';
12
import { Injectable } from '@angular/core';
23
import { Store } from '@ngrx/store';
3-
import { Observable } from 'rxjs';
4+
import {
5+
map,
6+
Observable,
7+
} from 'rxjs';
8+
import {
9+
distinctUntilChanged,
10+
take,
11+
takeWhile,
12+
} from 'rxjs/operators';
413

14+
import {
15+
hasValue,
16+
isNotEmptyOperator,
17+
} from '../../shared/empty.util';
18+
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
519
import { NotificationsService } from '../../shared/notifications/notifications.service';
20+
import { getClassForType } from '../cache/builders/build-decorators';
621
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
722
import { RequestParam } from '../cache/models/request-param.model';
823
import { ObjectCacheService } from '../cache/object-cache.service';
@@ -12,7 +27,10 @@ import {
1227
} from '../data/base/create-data';
1328
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
1429
import { RemoteData } from '../data/remote-data';
30+
import { CreateRequest } from '../data/request.models';
1531
import { RequestService } from '../data/request.service';
32+
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
33+
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
1634
import { HALEndpointService } from '../shared/hal-endpoint.service';
1735
import {
1836
getFirstSucceededRemoteData,
@@ -24,7 +42,9 @@ import { Feedback } from './models/feedback.model';
2442
* Service for checking and managing the feedback
2543
*/
2644
@Injectable({ providedIn: 'root' })
27-
export class FeedbackDataService extends IdentifiableDataService<Feedback> implements CreateData<Feedback> {
45+
export class FeedbackDataService
46+
extends IdentifiableDataService<Feedback>
47+
implements CreateData<Feedback> {
2848
private createData: CreateDataImpl<Feedback>;
2949

3050
constructor(
@@ -37,7 +57,15 @@ export class FeedbackDataService extends IdentifiableDataService<Feedback> imple
3757
) {
3858
super('feedbacks', requestService, rdbService, objectCache, halService);
3959

40-
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
60+
this.createData = new CreateDataImpl(
61+
this.linkPath,
62+
requestService,
63+
rdbService,
64+
objectCache,
65+
halService,
66+
notificationsService,
67+
this.responseMsToLive,
68+
);
4169
}
4270

4371
/**
@@ -51,14 +79,71 @@ export class FeedbackDataService extends IdentifiableDataService<Feedback> imple
5179
);
5280
}
5381

54-
5582
/**
5683
* Create a new object on the server, and store the response in the object cache
5784
*
5885
* @param object The object to create
5986
* @param params Array with additional params to combine with query string
6087
*/
61-
public create(object: Feedback, ...params: RequestParam[]): Observable<RemoteData<Feedback>> {
88+
public create(
89+
object: Feedback,
90+
...params: RequestParam[]
91+
): Observable<RemoteData<Feedback>> {
6292
return this.createData.create(object, ...params);
6393
}
94+
95+
createWithCaptcha(
96+
object: Feedback,
97+
captchaToken: string = null,
98+
...params: RequestParam[]
99+
): Observable<RemoteData<Feedback>> {
100+
const requestId = this.requestService.generateRequestId();
101+
102+
const endpoint$ = this.getEndpoint().pipe(
103+
isNotEmptyOperator(),
104+
distinctUntilChanged(),
105+
map((endpoint: string) => this.buildHrefWithParams(endpoint, params)),
106+
);
107+
108+
const serializedObject = new DSpaceSerializer(
109+
getClassForType(object.type),
110+
).serialize(object);
111+
112+
endpoint$.pipe(take(1)).subscribe((endpoint: string) => {
113+
const options: HttpOptions = Object.create({});
114+
let headers = new HttpHeaders();
115+
if (captchaToken) {
116+
headers = headers.set('x-recaptcha-token', captchaToken);
117+
}
118+
options.headers = headers;
119+
120+
const request = new CreateRequest(
121+
requestId,
122+
endpoint,
123+
JSON.stringify(serializedObject),
124+
options,
125+
);
126+
127+
if (hasValue(this.responseMsToLive)) {
128+
request.responseMsToLive = this.responseMsToLive;
129+
}
130+
this.requestService.send(request);
131+
});
132+
133+
const result$ = this.rdbService.buildFromRequestUUID<Feedback>(requestId);
134+
135+
result$
136+
.pipe(takeWhile((rd: RemoteData<Feedback>) => rd.isLoading, true))
137+
.subscribe((rd: RemoteData<Feedback>) => {
138+
if (rd.hasFailed) {
139+
this.notificationsService.error(
140+
'Server Error:',
141+
rd.errorMessage,
142+
new NotificationOptions(-1),
143+
);
144+
}
145+
});
146+
147+
return result$;
148+
}
64149
}

src/app/core/feedback/models/feedback.model.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export class Feedback extends DSpaceObject {
3030
@autoserialize
3131
public page: string;
3232

33+
/**
34+
* Google reCAPTCHA token for spam protection
35+
*/
36+
@autoserialize
37+
public captcha: string;
38+
3339
_links: {
3440
self: HALLink;
3541
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Component } from '@angular/core';
2+
import { TranslateService } from '@ngx-translate/core';
3+
import {
4+
BehaviorSubject,
5+
combineLatest,
6+
Observable,
7+
of,
8+
} from 'rxjs';
9+
import {
10+
startWith,
11+
switchMap,
12+
} from 'rxjs/operators';
13+
14+
import { CookieService } from '../services/cookie.service';
15+
import { isNotEmpty } from './../../shared/empty.util';
16+
import { NotificationsService } from './../../shared/notifications/notifications.service';
17+
import {
18+
CAPTCHA_FEEDBACK_NAME,
19+
GoogleRecaptchaService,
20+
} from './google-recaptcha.service';
21+
22+
@Component({
23+
template: '',
24+
})
25+
export abstract class GoogleRecaptchaBaseComponent {
26+
27+
/**
28+
* The message prefix - default value is 'submit-form'.
29+
* Add translations if a different value is used.
30+
*/
31+
MESSAGE_PREFIX = 'submit-form';
32+
33+
constructor(
34+
public googleRecaptchaService: GoogleRecaptchaService,
35+
public cookieService: CookieService,
36+
public notificationsService: NotificationsService,
37+
public translate: TranslateService,
38+
) {}
39+
40+
/**
41+
* Return true if the user completed the reCaptcha verification (checkbox mode)
42+
*/
43+
checkboxCheckedSubject$ = new BehaviorSubject<boolean>(false);
44+
45+
/**
46+
* Disable the form until the user has completed the reCaptcha verification (checkbox mode)
47+
*/
48+
disableUntilChecked = true;
49+
50+
captchaVersion(): Observable<string> {
51+
return this.googleRecaptchaService.captchaVersion();
52+
}
53+
54+
captchaMode(): Observable<string> {
55+
return this.googleRecaptchaService.captchaMode();
56+
}
57+
58+
/**
59+
* Return true if the user has not completed the reCaptcha verification (checkbox mode)
60+
*/
61+
disableUntilCheckedFcn(): Observable<boolean> {
62+
const checked$ = this.checkboxCheckedSubject$.asObservable();
63+
return combineLatest([
64+
this.captchaVersion(),
65+
this.captchaMode(),
66+
checked$,
67+
]).pipe(
68+
// disable if checkbox is not checked or if reCaptcha is not in v2 checkbox mode
69+
switchMap(([captchaVersion, captchaMode, checked]) =>
70+
captchaVersion === 'v2' && captchaMode === 'checkbox'
71+
? of(!checked)
72+
: of(false),
73+
),
74+
startWith(true),
75+
);
76+
}
77+
78+
/**
79+
* Return true if the user has accepted the required cookies for reCaptcha
80+
*/
81+
isRecaptchaCookieAccepted(): boolean {
82+
const orejimeAnonymousCookie = this.cookieService.get('orejime-anonymous');
83+
return isNotEmpty(orejimeAnonymousCookie)
84+
? orejimeAnonymousCookie[CAPTCHA_FEEDBACK_NAME]
85+
: false;
86+
}
87+
88+
/**
89+
* Set the checkbox checked status to the given value
90+
*/
91+
onCheckboxChecked(checked: boolean) {
92+
this.checkboxCheckedSubject$.next(checked);
93+
}
94+
95+
/**
96+
* Show a notification to the user
97+
* @param key expected values: 'expired' or 'error'
98+
*/
99+
showNotification(key) {
100+
const notificationTitle = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.title');
101+
const notificationErrorMsg = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.error');
102+
const notificationExpiredMsg = this.translate.get(this.MESSAGE_PREFIX + '.google-recaptcha.notification.message.expired');
103+
switch (key) {
104+
case 'expired':
105+
this.notificationsService.warning(notificationTitle, notificationExpiredMsg);
106+
break;
107+
case 'error':
108+
this.notificationsService.error(notificationTitle, notificationErrorMsg);
109+
break;
110+
default:
111+
console.warn(`Unimplemented notification '${key}' from reCaptcha service`);
112+
}
113+
}
114+
115+
/**
116+
* execute the captcha function
117+
*/
118+
executeRecaptcha() {
119+
this.googleRecaptchaService.executeRecaptcha();
120+
}
121+
}

0 commit comments

Comments
 (0)