diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.spec.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.spec.ts new file mode 100644 index 000000000000..69272813a379 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.spec.ts @@ -0,0 +1,114 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { + DotReportIssueContentlet, + DotReportIssuePayload, + DotReportIssueService +} from './dot-report-issue.service'; + +const MOCK_REPORT_ISSUE_CONTENTLET: DotReportIssueContentlet = { + archived: false, + baseType: 'CONTENT', + contentType: 'Bug', + folder: 'SYSTEM_FOLDER', + hasTitleImage: false, + host: 'host-id', + hostName: 'dotcms.dev', + identifier: 'identifier-id', + inode: 'inode-id', + languageId: 1, + live: false, + locked: false, + modDate: '1778806040235', + modUser: 'user-id', + modUserName: 'Bug Reporter', + owner: 'user-id', + sortOrder: 0, + stInode: 'stInode-id', + title: '/plugins [1.0.0-SNAPSHOT - Chrome]', + titleImage: 'screenshot', + url: '/content.inode-id', + working: true, + metadata: { + browser: 'Chrome', + url: 'http://localhost:8080/dotAdmin/#/plugins?mId=1a87' + } +}; + +describe('DotReportIssueService', () => { + let service: DotReportIssueService; + let httpTesting: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), DotReportIssueService] + }); + + service = TestBed.inject(DotReportIssueService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should submit report issue multipart payload with description and metadata', () => { + const payload: DotReportIssuePayload = { + description: 'Login button is unresponsive', + metadata: { + browser: 'Chrome', + url: 'http://localhost:8080/dotAdmin' + } + }; + + service.reportIssue(payload).subscribe((response) => { + expect(response).toEqual(MOCK_REPORT_ISSUE_CONTENTLET); + }); + + const req = httpTesting.expectOne('/api/v1/report-issue'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toBeInstanceOf(FormData); + expect(req.request.body.get('description')).toBe(payload.description); + expect(req.request.body.get('metadata')).toBe(JSON.stringify(payload.metadata)); + expect(req.request.body.get('anonymous')).toBe('false'); + expect(req.request.body.get('screenshot')).toBeNull(); + + req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET }); + }); + + it('should send anonymous=true when payload anonymous is true', () => { + const payload: DotReportIssuePayload = { + description: 'Anonymous report', + metadata: { browser: 'Chrome' }, + anonymous: true + }; + + service.reportIssue(payload).subscribe(); + + const req = httpTesting.expectOne('/api/v1/report-issue'); + expect(req.request.body.get('anonymous')).toBe('true'); + + req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET }); + }); + + it('should include screenshot when provided', () => { + const screenshot = new File(['image'], 'screenshot.png', { type: 'image/png' }); + const payload: DotReportIssuePayload = { + description: 'Issue with screenshot', + metadata: { + browser: 'Safari' + }, + screenshot + }; + + service.reportIssue(payload).subscribe(); + + const req = httpTesting.expectOne('/api/v1/report-issue'); + expect(req.request.method).toBe('POST'); + expect(req.request.body.get('screenshot')).toBe(screenshot); + + req.flush({ entity: MOCK_REPORT_ISSUE_CONTENTLET }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.ts b/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.ts new file mode 100644 index 000000000000..6952b5a89b5c --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/api/services/dot-report-issue.service.ts @@ -0,0 +1,92 @@ +import { Observable } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; + +import { map } from 'rxjs/operators'; + +import { DotCMSContentlet, DotCMSResponse } from '@dotcms/dotcms-models'; + +export interface DotReportIssuePayload { + description: string; + metadata: Record; + screenshot?: File | null; + anonymous?: boolean; +} + +export interface DotReportIssueUserMetadata { + email: string; + fullName: string; + userId: string; +} + +export interface DotReportIssueMetadata { + browser?: string; + dotcmsBuildDate?: string; + dotcmsVersion?: string; + platform?: string; + referer?: string; + referrer?: string; + remoteAddress?: string; + requestUrl?: string; + serverName?: string; + submittedAt?: string; + url?: string; + user?: DotReportIssueUserMetadata; + userAgent?: string; + viewport?: string; + [key: string]: unknown; +} + +export interface DotReportIssueScreenshotMetadata { + contentType: string; + editableAsText: boolean; + fileSize: number; + height?: number; + isImage: boolean; + length: number; + modDate: number; + name: string; + sha256: string; + title: string; + version: number; + width?: number; +} + +export interface DotReportIssueContentlet extends DotCMSContentlet { + metadata?: DotReportIssueMetadata; + screenshot?: string; + screenshotContentAsset?: string; + screenshotMetaData?: DotReportIssueScreenshotMetadata; + screenshotVersion?: string; +} + +/** + * Client service for creating issue reports through the core backend endpoint. + */ +@Injectable() +export class DotReportIssueService { + private readonly http = inject(HttpClient); + + /** + * Submit a report issue request to the backend using multipart form data. + * + * @param payload - Report details and optional screenshot attachment. + * @returns The created report issue contentlet returned by the backend. + */ + reportIssue(payload: DotReportIssuePayload): Observable { + const formData = new FormData(); + + formData.append('description', payload.description); + formData.append('metadata', JSON.stringify(payload.metadata)); + formData.append('anonymous', String(payload.anonymous === true)); + + if (payload.screenshot) { + formData.append('screenshot', payload.screenshot); + } + + return this.http + .post>('/api/v1/report-issue', formData) + .pipe(map((response) => response.entity)); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/providers.ts b/core-web/apps/dotcms-ui/src/app/providers.ts index 77a2a8405624..7dc4eaba204c 100644 --- a/core-web/apps/dotcms-ui/src/app/providers.ts +++ b/core-web/apps/dotcms-ui/src/app/providers.ts @@ -52,6 +52,7 @@ import { DotAccountService } from './api/services/dot-account-service'; import { DotDownloadBundleDialogService } from './api/services/dot-download-bundle-dialog/dot-download-bundle-dialog.service'; import { DotMenuService } from './api/services/dot-menu.service'; import { DotParseHtmlService } from './api/services/dot-parse-html/dot-parse-html.service'; +import { DotReportIssueService } from './api/services/dot-report-issue.service'; import { AuthGuardService } from './api/services/guards/auth-guard.service'; import { ContentletGuardService } from './api/services/guards/contentlet-guard.service'; import { DefaultGuardService } from './api/services/guards/default-guard.service'; @@ -100,6 +101,7 @@ const PROVIDERS: Provider[] = [ DotMessageService, DotParseHtmlService, DotPushPublishFiltersService, + DotReportIssueService, DotRolesService, DotRouterService, DotSaveOnDeactivateService, diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.html b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.html new file mode 100644 index 000000000000..1c6bf7dd3cf4 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.html @@ -0,0 +1,100 @@ + +
+ @if (errorMessage()) { +

+ {{ errorMessage() }} +

+ } +
+ + + @if (form.get('description')?.invalid && hasSubmitted()) { + + {{ errorMessages.descriptionRequired }} + + } +
+ +
+ + + + @if (hasSubmitted() && screenshotErrorMessage(); as message) { + + {{ message }} + + } +
+ +
+ + +
+ @if (!operatorAllowsPII()) { + + {{ 'report-an-issue.anonymous.enforced' | dm }} + + } +
+ + + + + +
diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts new file mode 100644 index 000000000000..2c5a5d979b73 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.spec.ts @@ -0,0 +1,268 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of, Subject, throwError } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; + +import { FileSelectEvent } from 'primeng/fileupload'; + +import { + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService, + DotPropertiesService +} from '@dotcms/data-access'; + +import { DotReportIssueComponent } from './dot-report-issue.component'; + +import { DotReportIssueService } from '../../../api/services/dot-report-issue.service'; +import { LOCATION_TOKEN } from '../../../providers'; + +describe('DotReportIssueComponent', () => { + let spectator: Spectator; + let component: DotReportIssueComponent; + + const reportIssueMock = jest.fn(() => of('')); + const successMock = jest.fn(); + const handleMock = jest.fn(() => of({})); + const getKeyMock = jest.fn(() => of(true as string | boolean)); + + const createComponent = createComponentFactory({ + component: DotReportIssueComponent, + providers: [ + mockProvider(DotReportIssueService, { + reportIssue: reportIssueMock + }), + mockProvider(DotGlobalMessageService, { + success: successMock + }), + mockProvider(DotHttpErrorManagerService, { + handle: handleMock + }), + mockProvider(DotMessageService, { + get: (key: string) => key + }), + mockProvider(DotPropertiesService, { + getKey: getKeyMock + }), + { + provide: LOCATION_TOKEN, + useValue: { + href: 'http://localhost:8080/dotAdmin' + } + } + ] + }); + + beforeEach(() => { + reportIssueMock.mockReset(); + reportIssueMock.mockReturnValue(of('')); + successMock.mockReset(); + handleMock.mockReset(); + handleMock.mockReturnValue(of({})); + getKeyMock.mockReset(); + getKeyMock.mockReturnValue(of(true)); + + spectator = createComponent(); + spectator.setInput('visible', true); + component = spectator.component; + }); + + it('should require a non-empty description', () => { + component.form.get('description')?.setValue(' '); + component.save(); + spectator.detectChanges(); + + expect(component.form.get('description')?.hasError('required')).toBe(true); + expect( + document.querySelector('[data-testid="dot-report-issue-description-error"]') + ).toBeTruthy(); + expect(reportIssueMock).not.toHaveBeenCalled(); + }); + + it('should keep submit enabled before a submit attempt when the form is invalid', () => { + spectator.detectChanges(); + + expect(component.form.invalid).toBe(true); + expect(spectator.query('[data-testid="dot-report-issue-description-error"]')).toBeFalsy(); + expect( + spectator.query('[data-testid="dot-report-issue-submit-button"]') + ).not.toBeDisabled(); + }); + + it('should reject an invalid screenshot mime type', () => { + const file = new File(['bad'], 'screenshot.gif', { type: 'image/gif' }); + component.form.get('description')?.setValue('Broken publish button'); + + component.onScreenshotSelected({ + files: [file] + } as FileSelectEvent); + component.save(); + spectator.detectChanges(); + + expect(component.form.get('screenshot')?.hasError('invalidFileType')).toBe(true); + expect( + document.querySelector('[data-testid="dot-report-issue-screenshot-error"]') + ).toBeTruthy(); + }); + + it('should reject an oversized screenshot', () => { + const largeFile = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.png', { + type: 'image/png' + }); + component.form.get('description')?.setValue('Broken publish button'); + + component.onScreenshotSelected({ + files: [largeFile] + } as FileSelectEvent); + component.save(); + spectator.detectChanges(); + + expect(component.form.get('screenshot')?.hasError('maxFileSize')).toBe(true); + }); + + it('should remove the selected screenshot', () => { + const file = new File(['image'], 'screenshot.png', { type: 'image/png' }); + + component.onScreenshotSelected({ + files: [file] + } as FileSelectEvent); + + component.removeScreenshot(); + + expect(component.screenshotFile()).toBeNull(); + expect(component.form.get('screenshot')?.value).toBeNull(); + expect(component.form.get('screenshot')?.valid).toBe(true); + }); + + it('should disable submit while request is in flight', () => { + const response$ = new Subject(); + reportIssueMock.mockReturnValue(response$); + + component.form.get('description')?.setValue('Report issue'); + spectator.detectChanges(); + + component.save(); + spectator.detectChanges(); + + expect(component.isSubmitting()).toBe(true); + expect(reportIssueMock).toHaveBeenCalledTimes(1); + + response$.next(''); + response$.complete(); + }); + + it('should submit a trimmed description, close, and show success message', () => { + jest.spyOn(component.shutdown, 'emit'); + + component.form.get('description')?.setValue(' Broken button on editor '); + component.save(); + + expect(reportIssueMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Broken button on editor', + screenshot: null, + anonymous: false, + metadata: expect.objectContaining({ + browser: window.navigator.userAgent, + platform: window.navigator.platform, + url: 'http://localhost:8080/dotAdmin', + viewport: `${window.innerWidth}x${window.innerHeight}` + }) + }) + ); + expect(successMock).toHaveBeenCalledWith('report-an-issue.success'); + expect(component.visible()).toBe(false); + expect(component.shutdown.emit).not.toHaveBeenCalled(); + }); + + it('should emit shutdown once when the dialog hides', () => { + jest.spyOn(component.shutdown, 'emit'); + + component.form.get('description')?.setValue('Close me'); + component.requestClose(); + + expect(component.visible()).toBe(false); + expect(component.shutdown.emit).not.toHaveBeenCalled(); + + component.onDialogHide(); + + expect(component.shutdown.emit).toHaveBeenCalledTimes(1); + }); + + it('should forward the anonymous flag when the checkbox is checked', () => { + component.form.get('description')?.setValue('Anonymous report'); + component.form.get('anonymous')?.setValue(true); + component.save(); + + expect(reportIssueMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Anonymous report', + anonymous: true + }) + ); + }); + + it('should disable the anonymous checkbox and show enforcement hint when operator disallows PII', () => { + getKeyMock.mockReturnValue(of(false)); + spectator = createComponent(); + spectator.setInput('visible', true); + component = spectator.component; + spectator.detectChanges(); + + expect(component.form.get('anonymous')?.disabled).toBe(true); + expect(component.form.get('anonymous')?.value).toBe(true); + expect(spectator.query('[data-testid="dot-report-issue-anonymous-enforced"]')).toBeTruthy(); + }); + + it('should keep the dialog open and preserve values on error', () => { + const screenshot = new File(['image'], 'screenshot.png', { type: 'image/png' }); + jest.spyOn(component.shutdown, 'emit'); + reportIssueMock.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 502, statusText: 'Bad Gateway' })) + ); + + component.form.get('description')?.setValue('Broken publish button'); + component.onScreenshotSelected({ + files: [screenshot] + } as FileSelectEvent); + + component.save(); + + expect(component.isSubmitting()).toBe(false); + expect(component.form.get('description')?.value).toBe('Broken publish button'); + expect(component.screenshotFile()).toBe(screenshot); + expect(component.shutdown.emit).not.toHaveBeenCalled(); + expect(handleMock).toHaveBeenCalled(); + expect(component.errorMessage()).toBeTruthy(); + }); + + it('should surface a backend media type error and stop loading', () => { + const screenshot = new File(['image'], 'screenshot.png', { type: 'image/png' }); + reportIssueMock.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 415, + error: JSON.stringify({ + message: 'HTTP 415 Unsupported Media Type' + }) + }) + ) + ); + + component.form.get('description')?.setValue('Broken publish button'); + component.onScreenshotSelected({ + files: [screenshot] + } as FileSelectEvent); + + component.save(); + spectator.detectChanges(); + + expect(component.isSubmitting()).toBe(false); + expect(component.errorMessage()).toBe('HTTP 415 Unsupported Media Type'); + expect(handleMock).toHaveBeenCalled(); + expect(spectator.query('[data-testid="dot-report-issue-error-message"]'))?.toHaveText( + 'HTTP 415 Unsupported Media Type' + ); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts new file mode 100644 index 000000000000..6449b56750f0 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-report-issue/dot-report-issue.component.ts @@ -0,0 +1,357 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + model, + output, + signal, + viewChild +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { CheckboxModule } from 'primeng/checkbox'; +import { DialogModule } from 'primeng/dialog'; +import { FileSelectEvent, FileUpload, FileUploadModule } from 'primeng/fileupload'; +import { TextareaModule } from 'primeng/textarea'; + +import { map } from 'rxjs/operators'; + +import { + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService, + DotPropertiesService +} from '@dotcms/data-access'; +import { DotFieldRequiredDirective, DotMessagePipe } from '@dotcms/ui'; + +import { + DotReportIssuePayload, + DotReportIssueService +} from '../../../api/services/dot-report-issue.service'; +import { LOCATION_TOKEN } from '../../../providers'; + +const ALLOWED_SCREENSHOT_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); +const MAX_SCREENSHOT_SIZE_BYTES = 10 * 1024 * 1024; + +/** + * Dialog used to collect and submit issue reports from the toolbar user menu. + */ +@Component({ + selector: 'dot-report-issue', + templateUrl: './dot-report-issue.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ReactiveFormsModule, + DialogModule, + ButtonModule, + CheckboxModule, + FileUploadModule, + TextareaModule, + DotFieldRequiredDirective, + DotMessagePipe + ] +}) +export class DotReportIssueComponent { + readonly shutdown = output(); + readonly visible = model(false); + + private readonly fb = inject(FormBuilder); + private readonly dotMessageService = inject(DotMessageService); + private readonly dotGlobalMessageService = inject(DotGlobalMessageService); + private readonly dotHttpErrorManagerService = inject(DotHttpErrorManagerService); + private readonly dotReportIssueService = inject(DotReportIssueService); + private readonly dotPropertiesService = inject(DotPropertiesService); + private readonly location = inject(LOCATION_TOKEN); + private readonly document = inject(DOCUMENT); + private readonly screenshotUploadRef = viewChild('screenshotUpload'); + + readonly operatorAllowsPII = toSignal( + this.dotPropertiesService + .getKey('boolean:REPORT_ISSUE_INCLUDE_USER_PII') + .pipe(map((value) => value !== false && value !== 'false')), + { initialValue: true } + ); + + readonly form: FormGroup = this.fb.group({ + description: ['', [Validators.required, this.trimmedRequiredValidator()]], + screenshot: [null as File | null, [this.screenshotValidator()]], + anonymous: [false] + }); + + readonly screenshotFile = signal(null); + readonly isSubmitting = signal(false); + readonly hasSubmitted = signal(false); + readonly errorMessage = signal(''); + + readonly errorMessages = { + descriptionRequired: this.dotMessageService.get( + 'error.form.mandatory', + this.dotMessageService.get('report-an-issue.description') + ), + invalidFileType: this.dotMessageService.get('report-an-issue.screenshot.invalid-type'), + maxFileSize: this.dotMessageService.get('report-an-issue.screenshot.max-size') + }; + + private readonly formStatus = toSignal(this.form.statusChanges, { + initialValue: this.form.status + }); + + readonly screenshotErrorMessage = computed(() => { + this.formStatus(); + const errors = this.form.get('screenshot')?.errors; + + if (!errors) { + return null; + } + + if (errors['invalidFileType']) { + return this.errorMessages.invalidFileType; + } + + if (errors['maxFileSize']) { + return this.errorMessages.maxFileSize; + } + + return null; + }); + + constructor() { + effect(() => { + if (this.visible()) { + this.resetForm(); + } + }); + + effect(() => { + const anonymousControl = this.form.get('anonymous'); + if (!anonymousControl) { + return; + } + if (this.operatorAllowsPII()) { + anonymousControl.enable({ emitEvent: false }); + } else { + anonymousControl.setValue(true, { emitEvent: false }); + anonymousControl.disable({ emitEvent: false }); + } + }); + } + + /** + * Request the dialog to close. + */ + requestClose(): void { + this.visible.set(false); + } + + /** + * Reset component state after the dialog has been hidden and notify the parent. + */ + onDialogHide(): void { + this.resetForm(); + this.shutdown.emit(); + } + + /** + * Capture the selected screenshot file and re-run screenshot validation. + * + * @param event - PrimeNG file selection event. + */ + onScreenshotSelected(event: FileSelectEvent): void { + const file = event.files?.[0] ?? null; + + this.screenshotFile.set(file); + this.errorMessage.set(''); + this.form.get('screenshot')?.setValue(file); + this.form.get('screenshot')?.markAsTouched(); + this.form.get('screenshot')?.updateValueAndValidity(); + } + + /** + * Clear the current screenshot selection from the form and uploader widget. + */ + removeScreenshot(): void { + this.screenshotFile.set(null); + this.errorMessage.set(''); + this.form.get('screenshot')?.setValue(null); + this.form.get('screenshot')?.markAsTouched(); + this.form.get('screenshot')?.updateValueAndValidity(); + + const uploader = this.screenshotUploadRef(); + if (uploader && uploader.files.length > 0) { + uploader.clear(); + } + } + + /** + * Handle the uploader clear event. + */ + onScreenshotClear(): void { + this.removeScreenshot(); + } + + /** + * Validate the form and submit the issue to the backend. + */ + save(): void { + this.hasSubmitted.set(true); + + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.errorMessage.set(''); + this.isSubmitting.set(true); + const payload = this.buildPayload(); + + this.dotReportIssueService.reportIssue(payload).subscribe({ + next: () => { + this.isSubmitting.set(false); + this.dotGlobalMessageService.success( + this.dotMessageService.get('report-an-issue.success') + ); + this.requestClose(); + }, + error: (error) => { + this.isSubmitting.set(false); + this.errorMessage.set(this.getRequestErrorMessage(error)); + this.dotHttpErrorManagerService.handle(error).subscribe(); + } + }); + } + + /** + * Build the payload sent to the report issue API, including browser metadata. + * + * @returns The multipart payload model for the report issue service. + */ + private buildPayload(): DotReportIssuePayload { + const description = (this.form.get('description')?.value as string).trim(); + const metadata: Record = { + browser: window.navigator.userAgent, + platform: window.navigator.platform, + url: this.location.href, + viewport: `${window.innerWidth}x${window.innerHeight}` + }; + + if (this.document.referrer) { + metadata.referrer = this.document.referrer; + } + + return { + description, + metadata, + screenshot: this.screenshotFile(), + anonymous: this.form.get('anonymous')?.value === true + }; + } + + /** + * Restore the dialog form to its initial state. + */ + private resetForm(): void { + this.isSubmitting.set(false); + this.hasSubmitted.set(false); + this.errorMessage.set(''); + this.screenshotFile.set(null); + this.form.reset({ + description: '', + screenshot: null, + anonymous: !this.operatorAllowsPII() + }); + } + + /** + * Resolve the most useful user-facing error message from a failed request. + * + * @param error - Request error returned by Angular's HTTP client. + * @returns A localized fallback or the best available backend error message. + */ + private getRequestErrorMessage(error: unknown): string { + const fallback = this.dotMessageService.get('report-an-issue.error'); + + if (typeof error !== 'object' || error === null) { + return fallback; + } + + const httpError = error as { + error?: { message?: string; errors?: Array<{ message?: string }> } | string; + message?: string; + statusText?: string; + }; + + if (typeof httpError.error === 'string') { + try { + const parsed = JSON.parse(httpError.error) as { message?: string }; + return parsed.message || httpError.message || fallback; + } catch { + return httpError.error || httpError.message || fallback; + } + } + + if (httpError.error?.errors?.[0]?.message) { + return httpError.error.errors[0].message; + } + + if (httpError.error?.message) { + return httpError.error.message; + } + + return httpError.message || httpError.statusText || fallback; + } + + /** + * Require a non-empty string after trimming whitespace. + * + * @returns A validator that fails when the control only contains whitespace. + */ + private trimmedRequiredValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + + if (typeof value !== 'string') { + return null; + } + + return value.trim().length ? null : { required: true }; + }; + } + + /** + * Validate screenshot type and file size before submission. + * + * @returns A validator for the optional screenshot control. + */ + private screenshotValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const file = control.value as File | null; + + if (!file) { + return null; + } + + if (!ALLOWED_SCREENSHOT_TYPES.has(file.type)) { + return { invalidFileType: true }; + } + + if (file.size > MAX_SCREENSHOT_SIZE_BYTES) { + return { maxFileSize: true }; + } + + return null; + }; + } +} diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.html b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.html index 15d24931a056..abc7909ad414 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.html +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.html @@ -25,4 +25,8 @@ @defer (when vm.showLoginAs) { } + + @defer (when $showReportIssue()) { + + } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts index 00cf9905fd6c..79a49dbdd884 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.spec.ts @@ -11,13 +11,21 @@ import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { DotMessageService, DotUiColorsService } from '@dotcms/data-access'; +import { take } from 'rxjs/operators'; + +import { + DotGlobalMessageService, + DotHttpErrorManagerService, + DotMessageService, + DotUiColorsService +} from '@dotcms/data-access'; import { LoggerService, LoginService } from '@dotcms/dotcms-js'; import { LoginServiceMock } from '@dotcms/utils-testing'; import { DotToolbarUserComponent } from './dot-toolbar-user.component'; import { DotToolbarUserStore } from './store/dot-toolbar-user.store'; +import { DotReportIssueService } from '../../../../../api/services/dot-report-issue.service'; import { LOCATION_TOKEN } from '../../../../../providers'; import { MockDotUiColorsService } from '../../../../../test/dot-test-bed'; import { DotNavigationService } from '../../../dot-navigation/services/dot-navigation.service'; @@ -49,6 +57,15 @@ describe('DotToolbarUserComponent', () => { }, { provide: LoggerService, useValue: { error: jest.fn() } }, { provide: DotMessageService, useValue: { get: (key: string) => key } }, + { provide: DotGlobalMessageService, useValue: { success: jest.fn() } }, + { + provide: DotHttpErrorManagerService, + useValue: { handle: jest.fn(() => of({})) } + }, + { + provide: DotReportIssueService, + useValue: { reportIssue: jest.fn(() => of('')) } + }, { provide: DotUiColorsService, useClass: MockDotUiColorsService }, DotToolbarUserStore ], @@ -222,4 +239,22 @@ describe('DotToolbarUserComponent', () => { expect(de.query(By.css('[data-testId="dot-mask"]'))).toBeNull(); }); + + it('should open the report issue dialog from the menu item command', fakeAsync(() => { + fixture.detectChanges(); + + let reportIssueCommand: (() => void) | undefined; + + fixture.componentInstance.vm$.pipe(take(1)).subscribe((vm) => { + reportIssueCommand = vm.items.find( + (item) => item.id === 'dot-toolbar-user-link-report-issue' + )?.command as (() => void) | undefined; + }); + + tick(); + reportIssueCommand?.(); + fixture.detectChanges(); + + expect(fixture.componentInstance.$showReportIssue()).toBe(true); + })); }); diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts index a077479cd6d5..5a491d6c5089 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/dot-toolbar-user.component.ts @@ -1,3 +1,5 @@ +import { map } from 'rxjs'; + import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, @@ -8,6 +10,7 @@ import { viewChild } from '@angular/core'; +import { MenuItem } from 'primeng/api'; import { AvatarModule } from 'primeng/avatar'; import { Menu, MenuModule } from 'primeng/menu'; @@ -15,6 +18,7 @@ import { DotGravatarDirective } from '@dotcms/ui'; import { DotToolbarUserStore } from './store/dot-toolbar-user.store'; +import { DotReportIssueComponent } from '../../../dot-report-issue/dot-report-issue.component'; import { DotLoginAsComponent } from '../dot-login-as/dot-login-as.component'; import { DotMyAccountComponent } from '../dot-my-account/dot-my-account.component'; @@ -28,6 +32,7 @@ import { DotMyAccountComponent } from '../dot-my-account/dot-my-account.componen AvatarModule, DotLoginAsComponent, DotMyAccountComponent, + DotReportIssueComponent, MenuModule, AsyncPipe ] @@ -35,7 +40,13 @@ import { DotMyAccountComponent } from '../dot-my-account/dot-my-account.componen export class DotToolbarUserComponent implements OnInit { readonly store = inject(DotToolbarUserStore); - vm$ = this.store.vm$; + readonly $showReportIssue = signal(false); + readonly vm$ = this.store.vm$.pipe( + map((vm) => ({ + ...vm, + items: this.withReportIssueCommand(vm.items) + })) + ); $menu = viewChild('menu'); $showMask = signal(false); @@ -43,12 +54,51 @@ export class DotToolbarUserComponent implements OnInit { this.store.init(); } + /** + * Toggle the user menu popup and the backdrop mask. + * + * @param event - Click event used by the PrimeNG menu popup. + */ toggleMenu(event: Event): void { this.$menu().toggle(event); this.$showMask.update((value) => !value); } + /** + * Hide the backdrop mask after the menu closes. + */ hideMask(): void { this.$showMask.update(() => false); } + + /** + * Open the report issue dialog from the user menu. + */ + openReportIssue(): void { + this.$showReportIssue.set(true); + } + + /** + * Close the report issue dialog. + */ + closeReportIssue(): void { + this.$showReportIssue.set(false); + } + + /** + * Attach the local dialog open handler to the report issue menu item. + * + * @param items - Menu items emitted by the toolbar user store. + * @returns A menu model with the report issue command bound locally. + */ + private withReportIssueCommand(items: MenuItem[]): MenuItem[] { + return items.map((item) => + item.id === 'dot-toolbar-user-link-report-issue' + ? { + ...item, + command: () => this.openReportIssue() + } + : item + ); + } } diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts index 887354fe70fa..32cae12a8cb5 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.spec.ts @@ -5,6 +5,8 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { fakeAsync, tick } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; +import { take } from 'rxjs/operators'; + import { DotCurrentUserService, DotEventsService, @@ -105,6 +107,23 @@ describe('DotToolbarUserStore', () => { }); }); + it('should include report an issue action in the menu', () => { + store.init(); + + store + .select((s) => s) + .pipe(take(1)) + .subscribe((state) => { + const reportIssueItem = state.items.find( + (item) => item.id === 'dot-toolbar-user-link-report-issue' + ); + + expect(reportIssueItem).toBeTruthy(); + expect(reportIssueItem?.label).toBe('report-an-issue'); + expect(reportIssueItem?.icon).toBe('pi pi-wrench'); + }); + }); + it('should trigger loginService logoutAs, navigate to first portlet and reload the page when logoutAs is called', fakeAsync(() => { jest.spyOn(dotNavigationService, 'goToFirstPortlet').mockReturnValue( new Promise((resolve) => { diff --git a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.ts b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.ts index 4c3017607187..a4a2454a7830 100644 --- a/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.ts +++ b/core-web/apps/dotcms-ui/src/app/view/components/dot-toolbar/components/dot-toolbar-user/store/dot-toolbar-user.store.ts @@ -132,7 +132,12 @@ export class DotToolbarUserStore extends ComponentStore { visible: !auth.isLoginAs, command: () => this.showLoginAs(true) }, - { separator: true, visible: !auth.isLoginAs }, + { + id: 'dot-toolbar-user-link-report-issue', + label: this.#dotMessageService.get('report-an-issue'), + icon: 'pi pi-wrench' + }, + { separator: true }, { id: 'dot-toolbar-user-link-logout', label: this.#dotMessageService.get('Logout'), diff --git a/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.spec.ts b/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.spec.ts index a45cff8bcae0..3ab149e996c1 100644 --- a/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.spec.ts @@ -39,6 +39,24 @@ describe('DotPropertiesService', () => { req.flush(fakeResponse); }); + it('should get boolean-prefixed key using the unprefixed response field', (done) => { + const key = 'boolean:REPORT_ISSUE_INCLUDE_USER_PII'; + const apiResponse = { + entity: { + REPORT_ISSUE_INCLUDE_USER_PII: false + } + }; + + service.getKey(key).subscribe((response) => { + expect(response).toBe(false); + done(); + }); + + const req = httpMock.expectOne(`/api/v1/configuration/config?keys=${key}`); + expect(req.request.method).toBe('GET'); + req.flush(apiResponse); + }); + it('should get ky as a list', (done) => { const key = 'list'; expect(service).toBeTruthy(); diff --git a/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.ts b/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.ts index b72c053121e0..ea9f59bd7ffc 100644 --- a/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.ts +++ b/core-web/libs/data-access/src/lib/dot-properties/dot-properties.service.ts @@ -25,13 +25,15 @@ export class DotPropertiesService { * @memberof DotPropertiesService */ getKey(key: string): Observable { + const responseKey = this.removePrefix(key); + return this.http .get< DotCMSResponse> >('/api/v1/configuration/config', { params: { keys: key } }) .pipe( take(1), - map((response) => response.entity[key] ?? FEATURE_FLAG_NOT_FOUND) + map((response) => response.entity[responseKey] ?? FEATURE_FLAG_NOT_FOUND) ); } @@ -73,6 +75,10 @@ export class DotPropertiesService { ); } + private removePrefix(key: string): string { + return key.replace(/^(list:|boolean:|number:)/, ''); + } + /** * Get the value of specific feature flag * @param {FeaturedFlags} key diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResource.java new file mode 100644 index 000000000000..b04e098961ea --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResource.java @@ -0,0 +1,780 @@ +package com.dotcms.rest.api.v1.reportissue; + +import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.FileUtil; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.util.ReleaseInfo; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.vavr.control.Try; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * Endpoint used by dotCMS UI to report product issues without exposing the upstream reporter token. + */ +@Path("/v1/report-issue") +@Tag(name = "Workflow") +public class ReportIssueResource { + + static final String WORKFLOW_URL_PROPERTY = "REPORT_ISSUE_WORKFLOW_URL"; + static final String DEFAULT_WORKFLOW_URL = + "https://corpsites-headless.dotcms.cloud/api/v1/workflow/actions/default/fire/NEW"; + + static final String ERROR_INVALID_REQUEST = "REPORT_ISSUE_INVALID_REQUEST"; + static final String ERROR_PROXY_NOT_AUTHORIZED = "REPORT_ISSUE_PROXY_NOT_AUTHORIZED"; + static final String ERROR_SERVICE_UNAVAILABLE = "REPORT_ISSUE_SERVICE_UNAVAILABLE"; + static final String ERROR_UPSTREAM_FAILED = "REPORT_ISSUE_UPSTREAM_FAILED"; + + static final String REPORT_ISSUE_INCLUDE_USER_PII_PROPERTY = "REPORT_ISSUE_INCLUDE_USER_PII"; + static final boolean DEFAULT_REPORT_ISSUE_INCLUDE_USER_PII = true; + + private static final String DESCRIPTION_FIELD = "description"; + private static final String METADATA_FIELD = "metadata"; + private static final String ANONYMOUS_FIELD = "anonymous"; + private static final String SCREENSHOT_FIELD = "screenshot"; + private static final String FILE_FIELD = "file"; + private static final String CONTENT_TYPE = "Bug"; + private static final String SCREENSHOT_CONTENT_TYPE_FIELD = "screenshot"; + private static final long DEFAULT_MAX_SCREENSHOT_BYTES = 10L * 1024L * 1024L; + private static final String MAX_SCREENSHOT_BYTES_PROPERTY = "REPORT_ISSUE_SCREENSHOT_MAX_BYTES"; + private static final Set ALLOWED_SCREENSHOT_TYPES = Set.of( + "image/png", + "image/jpeg", + "image/webp" + ); + + private final WebResource webResource; + private final ReportIssueForwarder forwarder; + private final ObjectMapper objectMapper; + + public ReportIssueResource() { + this(new WebResource(), new HttpReportIssueForwarder(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build())); + } + + ReportIssueResource(final WebResource webResource, final ReportIssueForwarder forwarder) { + this.webResource = webResource; + this.forwarder = forwarder; + this.objectMapper = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + } + + @POST + @NoCache + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + operationId = "reportIssue", + summary = "Report a dotCMS UI issue", + description = "Creates a Bug contentlet in the configured upstream reporting dotCMS instance.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Issue reported", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "Invalid report payload", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityView.class))), + @ApiResponse(responseCode = "502", description = "Reporting service unavailable or unauthorized", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityView.class))) + } + ) + public Response reportIssue( + @Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final FormDataMultiPart multipart) { + + final InitDataObject initDataObject = new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .rejectWhenNoUser(true) + .init(); + + try { + final ReportIssuePayload payload = buildPayload(request, initDataObject.getUser(), multipart); + final URI upstreamUri = URI.create(Config.getStringProperty(WORKFLOW_URL_PROPERTY, DEFAULT_WORKFLOW_URL)); + final ReportIssueForwardResponse upstreamResponse = this.forwarder.forward( + new ReportIssueForwardRequest( + upstreamUri, + payload.contentlet(), + payload.screenshot(), + payload.binaryFields())); + + return mapUpstreamResponse(upstreamResponse); + } catch (ReportIssueValidationException e) { + return error(Response.Status.BAD_REQUEST, ERROR_INVALID_REQUEST, e.getMessage()); + } catch (IllegalArgumentException e) { + Logger.warn(ReportIssueResource.class, "Invalid Report Issue configuration: " + e.getMessage()); + return error(Response.Status.BAD_GATEWAY, ERROR_SERVICE_UNAVAILABLE, + "Report issue service is not available."); + } catch (IOException e) { + Logger.error(ReportIssueResource.class, "Unable to reach Report Issue service: " + e.getMessage(), e); + return error(Response.Status.BAD_GATEWAY, ERROR_SERVICE_UNAVAILABLE, + "Report issue service is not available."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Logger.error(ReportIssueResource.class, "Report Issue service request was interrupted.", e); + return error(Response.Status.BAD_GATEWAY, ERROR_SERVICE_UNAVAILABLE, + "Report issue service is not available."); + } catch (Exception e) { + Logger.error(ReportIssueResource.class, "Unexpected Report Issue error: " + e.getMessage(), e); + return error(Response.Status.BAD_GATEWAY, ERROR_SERVICE_UNAVAILABLE, + "Report issue service is not available."); + } + } + + private ReportIssuePayload buildPayload( + final HttpServletRequest request, + final User user, + final FormDataMultiPart multipart) throws IOException { + + if (multipart == null) { + throw new ReportIssueValidationException("Report issue payload is required."); + } + + final String description = getRequiredTextField(multipart, DESCRIPTION_FIELD); + final Map clientMetadata = getClientMetadata(multipart); + final Optional screenshot = getScreenshot(multipart); + final boolean includeUserIdentity = shouldIncludeUserIdentity(multipart); + final Map metadata = buildMetadata(request, user, clientMetadata, includeUserIdentity); + + final Map contentlet = new LinkedHashMap<>(); + contentlet.put("contentType", CONTENT_TYPE); + contentlet.put("title", deriveTitle(request, metadata)); + contentlet.put("description", description); + contentlet.put("metadata", metadata); + + final List binaryFields = screenshot.isPresent() + ? List.of(SCREENSHOT_CONTENT_TYPE_FIELD) + : List.of(); + + return new ReportIssuePayload(contentlet, screenshot, binaryFields); + } + + private String getRequiredTextField(final FormDataMultiPart multipart, final String fieldName) { + final FormDataBodyPart field = multipart.getField(fieldName); + final String value = field == null ? null : field.getValue(); + if (!UtilMethods.isSet(value) || value.trim().isEmpty()) { + throw new ReportIssueValidationException("Description is required."); + } + return value.trim(); + } + + private Map getClientMetadata(final FormDataMultiPart multipart) throws IOException { + final FormDataBodyPart metadataField = multipart.getField(METADATA_FIELD); + if (metadataField == null || !UtilMethods.isSet(metadataField.getValue())) { + return Map.of(); + } + + try { + return this.objectMapper.readValue(metadataField.getValue(), new TypeReference<>() { + }); + } catch (Exception e) { + throw new ReportIssueValidationException("Metadata must be a valid JSON object."); + } + } + + private Optional getScreenshot(final FormDataMultiPart multipart) throws IOException { + final List screenshots = new ArrayList<>(); + addAll(screenshots, multipart.getFields(SCREENSHOT_FIELD)); + addAll(screenshots, multipart.getFields(FILE_FIELD)); + + if (screenshots.isEmpty()) { + return Optional.empty(); + } + if (screenshots.size() > 1) { + throw new ReportIssueValidationException("Only one screenshot can be uploaded."); + } + + final FormDataBodyPart screenshot = screenshots.get(0); + final String mediaType = screenshot.getMediaType() == null + ? "" + : screenshot.getMediaType().toString().toLowerCase(); + if (!ALLOWED_SCREENSHOT_TYPES.contains(mediaType)) { + throw new ReportIssueValidationException("Screenshot must be a PNG, JPEG, or WebP image."); + } + + final String rawFileName = screenshot.getContentDisposition() == null + ? "screenshot" + : screenshot.getContentDisposition().getFileName(); + final String fileName = UtilMethods.isSet(rawFileName) + ? FileUtil.sanitizeFileName(rawFileName) + : "screenshot"; + + final byte[] bytes; + try (InputStream inputStream = getScreenshotStream(screenshot)) { + bytes = readBytesWithLimit(inputStream, + Config.getLongProperty(MAX_SCREENSHOT_BYTES_PROPERTY, DEFAULT_MAX_SCREENSHOT_BYTES)); + } + + return Optional.of(new ReportIssueScreenshot(fileName, mediaType, bytes)); + } + + private static void addAll(final List destination, final List source) { + if (source != null) { + destination.addAll(source); + } + } + + private static byte[] readBytesWithLimit(final InputStream inputStream, final long maxBytes) + throws IOException { + if (inputStream == null) { + throw new ReportIssueValidationException("Screenshot is invalid."); + } + + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + long totalBytes = 0L; + int bytesRead; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + totalBytes += bytesRead; + if (totalBytes > maxBytes) { + throw new ReportIssueValidationException("Screenshot exceeds the maximum allowed size."); + } + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } + + private static InputStream getScreenshotStream(final FormDataBodyPart screenshot) { + if (screenshot instanceof StreamDataBodyPart) { + return ((StreamDataBodyPart) screenshot).getStreamEntity(); + } + + return screenshot.getEntityAs(InputStream.class); + } + + private String deriveTitle(final HttpServletRequest request, final Map metadata) { + final String path = getReportedPath(metadata, request); + final BrowserDescriptor browserDescriptor = getBrowserDescriptor(metadata); + final String version = ReleaseInfo.getVersion(); + + final StringBuilder title = new StringBuilder(path); + if (UtilMethods.isSet(version) && browserDescriptor.isPresent()) { + title.append(" [") + .append(version) + .append(" - ") + .append(browserDescriptor.browser()) + .append("]"); + } else if (UtilMethods.isSet(version)) { + title.append(" [").append(version).append("]"); + } + + return title.length() <= 120 ? title.toString() : title.substring(0, 120); + } + + private String getReportedPath(final Map metadata, final HttpServletRequest request) { + final String metadataUrl = stringValue(clientMetadata(metadata).get("url")); + if (UtilMethods.isSet(metadataUrl)) { + try { + final URI uri = new URI(metadataUrl); + if (UtilMethods.isSet(uri.getFragment())) { + return normalizeReportedPath(uri.getFragment()); + } + return normalizeReportedPath(uri.getPath()); + } catch (URISyntaxException e) { + Logger.debug(ReportIssueResource.class, "Unable to parse report issue URL metadata: " + metadataUrl); + } + } + + return normalizeReportedPath(request.getRequestURI()); + } + + private String normalizeReportedPath(final String rawPath) { + if (!UtilMethods.isSet(rawPath)) { + return "/unknown"; + } + + String normalized = rawPath.trim(); + final int querySeparator = normalized.indexOf('?'); + if (querySeparator >= 0) { + normalized = normalized.substring(0, querySeparator); + } + + if (normalized.startsWith("#")) { + normalized = normalized.substring(1); + } + + if (normalized.startsWith("/dotAdmin")) { + normalized = normalized.substring("/dotAdmin".length()); + } + + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + + return UtilMethods.isSet(normalized) ? normalized : "/unknown"; + } + + private BrowserDescriptor getBrowserDescriptor(final Map metadata) { + final String browserMetadata = stringValue(clientMetadata(metadata).get("browser")); + if (!UtilMethods.isSet(browserMetadata)) { + return BrowserDescriptor.empty(); + } + + final BrowserDescriptor directDescriptor = parseDirectBrowserDescriptor(browserMetadata); + if (directDescriptor.isPresent()) { + return directDescriptor; + } + + return parseUserAgent(browserMetadata); + } + + private BrowserDescriptor parseDirectBrowserDescriptor(final String browserMetadata) { + final String trimmed = browserMetadata.trim(); + final String[] parts = trimmed.split("\\s+"); + if (parts.length < 2) { + return BrowserDescriptor.empty(); + } + + final String version = parts[parts.length - 1]; + if (!version.matches("\\d+(?:[._]\\d+)*")) { + return BrowserDescriptor.empty(); + } + + final String browser = trimmed.substring(0, trimmed.length() - version.length()).trim(); + if (!UtilMethods.isSet(browser)) { + return BrowserDescriptor.empty(); + } + + return new BrowserDescriptor(browser); + } + + private BrowserDescriptor parseUserAgent(final String userAgent) { + final List patterns = List.of( + new BrowserPattern("Edg/", "Edge"), + new BrowserPattern("OPR/", "Opera"), + new BrowserPattern("Chrome/", "Chrome"), + new BrowserPattern("Firefox/", "Firefox"), + new BrowserPattern("Version/", "Safari") + ); + + for (final BrowserPattern pattern : patterns) { + final String version = extractVersion(userAgent, pattern.token()); + if (UtilMethods.isSet(version)) { + return new BrowserDescriptor(pattern.browser()); + } + } + + return BrowserDescriptor.empty(); + } + + private String extractVersion(final String source, final String token) { + final int index = source.indexOf(token); + if (index < 0) { + return null; + } + + final int start = index + token.length(); + final StringBuilder version = new StringBuilder(); + for (int i = start; i < source.length(); i++) { + final char current = source.charAt(i); + if (Character.isDigit(current) || current == '.' || current == '_') { + version.append(current); + } else { + break; + } + } + + return version.length() == 0 ? null : version.toString(); + } + + private String stringValue(final Object value) { + return value == null ? null : String.valueOf(value); + } + + @SuppressWarnings("unchecked") + private Map clientMetadata(final Map metadata) { + final Object client = metadata.get("client"); + return client instanceof Map ? (Map) client : Map.of(); + } + + private Map buildMetadata( + final HttpServletRequest request, + final User user, + final Map clientMetadata, + final boolean includeUserIdentity) { + + final Map metadata = new LinkedHashMap<>(); + metadata.put("submittedAt", Instant.now().toString()); + metadata.put("dotcmsVersion", ReleaseInfo.getVersion()); + metadata.put("dotcmsBuildDate", ReleaseInfo.getBuildDateString()); + metadata.put("userAgent", request.getHeader("User-Agent")); + metadata.put("referer", request.getHeader("Referer")); + metadata.put("requestUrl", getRequestUrl(request)); + metadata.put("remoteAddress", request.getRemoteAddr()); + metadata.put("serverName", request.getServerName()); + metadata.put("anonymous", !includeUserIdentity); + + if (user != null && includeUserIdentity) { + final Map userMetadata = new LinkedHashMap<>(); + userMetadata.put("userId", Try.of(user::getUserId).getOrNull()); + userMetadata.put("email", Try.of(user::getEmailAddress).getOrNull()); + userMetadata.put("fullName", Try.of(user::getFullName).getOrNull()); + metadata.put("user", userMetadata); + } + + if (clientMetadata != null && !clientMetadata.isEmpty()) { + metadata.put("client", clientMetadata); + } + + return metadata; + } + + /** + * Decides whether to include user identity (userId, email, fullName) in the forwarded + * payload. The operator config flag is authoritative — when disabled, the user's + * "anonymous" toggle cannot opt back into sending PII. + */ + private boolean shouldIncludeUserIdentity(final FormDataMultiPart multipart) { + final boolean operatorAllowsPII = Config.getBooleanProperty( + REPORT_ISSUE_INCLUDE_USER_PII_PROPERTY, DEFAULT_REPORT_ISSUE_INCLUDE_USER_PII); + if (!operatorAllowsPII) { + return false; + } + + final FormDataBodyPart anonymousField = multipart.getField(ANONYMOUS_FIELD); + if (anonymousField == null) { + return true; + } + final String value = anonymousField.getValue(); + return !"true".equalsIgnoreCase(value == null ? "" : value.trim()); + } + + private String getRequestUrl(final HttpServletRequest request) { + final StringBuffer requestURL = request.getRequestURL(); + if (requestURL == null) { + return null; + } + + final String queryString = request.getQueryString(); + return queryString == null ? requestURL.toString() : requestURL + "?" + queryString; + } + + private Response mapUpstreamResponse(final ReportIssueForwardResponse upstreamResponse) { + final int statusCode = upstreamResponse.statusCode(); + + if (statusCode == Response.Status.UNAUTHORIZED.getStatusCode() + || statusCode == Response.Status.FORBIDDEN.getStatusCode()) { + return error(Response.Status.BAD_GATEWAY, ERROR_PROXY_NOT_AUTHORIZED, + "Report issue service is not authorized. Check the User Proxy plugin configuration."); + } + + if (statusCode >= 200 && statusCode < 300) { + return Response.status(statusCode) + .entity(upstreamResponse.body()) + .type(upstreamResponse.contentType().orElse(MediaType.APPLICATION_JSON)) + .build(); + } + + if (statusCode >= 400 && statusCode < 500 && UtilMethods.isSet(upstreamResponse.body())) { + return Response.status(statusCode) + .entity(upstreamResponse.body()) + .type(upstreamResponse.contentType().orElse(MediaType.APPLICATION_JSON)) + .build(); + } + + Logger.warn(ReportIssueResource.class, "Report Issue upstream failed with status: " + statusCode); + return error(Response.Status.BAD_GATEWAY, ERROR_UPSTREAM_FAILED, + "Report issue service failed to process the report."); + } + + private Response error(final Response.Status status, final String code, final String message) { + return Response.status(status) + .entity(new ResponseEntityView<>(List.of(new ErrorEntity(code, message)))) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} + +final class ReportIssuePayload { + private final Map contentlet; + private final Optional screenshot; + private final List binaryFields; + + ReportIssuePayload( + final Map contentlet, + final Optional screenshot, + final List binaryFields) { + this.contentlet = contentlet; + this.screenshot = screenshot; + this.binaryFields = binaryFields; + } + + Map contentlet() { + return contentlet; + } + + Optional screenshot() { + return screenshot; + } + + List binaryFields() { + return binaryFields; + } +} + +final class ReportIssueScreenshot { + private final String fileName; + private final String mediaType; + private final byte[] bytes; + + ReportIssueScreenshot(final String fileName, final String mediaType, final byte[] bytes) { + this.fileName = fileName; + this.mediaType = mediaType; + this.bytes = bytes; + } + + String fileName() { + return fileName; + } + + String mediaType() { + return mediaType; + } + + byte[] bytes() { + return bytes; + } +} + +final class ReportIssueForwardRequest { + private final URI upstreamUri; + private final Map contentlet; + private final Optional screenshot; + private final List binaryFields; + + ReportIssueForwardRequest( + final URI upstreamUri, + final Map contentlet, + final Optional screenshot, + final List binaryFields) { + this.upstreamUri = upstreamUri; + this.contentlet = contentlet; + this.screenshot = screenshot; + this.binaryFields = binaryFields; + } + + URI upstreamUri() { + return upstreamUri; + } + + Map contentlet() { + return contentlet; + } + + Optional screenshot() { + return screenshot; + } + + List binaryFields() { + return binaryFields; + } +} + +final class ReportIssueForwardResponse { + private final int statusCode; + private final String body; + private final Optional contentType; + + ReportIssueForwardResponse( + final int statusCode, + final String body, + final Optional contentType) { + this.statusCode = statusCode; + this.body = body; + this.contentType = contentType; + } + + int statusCode() { + return statusCode; + } + + String body() { + return body; + } + + Optional contentType() { + return contentType; + } +} + +interface ReportIssueForwarder { + ReportIssueForwardResponse forward(ReportIssueForwardRequest request) throws IOException, InterruptedException; +} + +class ReportIssueValidationException extends RuntimeException { + ReportIssueValidationException(final String message) { + super(message); + } +} + +class HttpReportIssueForwarder implements ReportIssueForwarder { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + HttpReportIssueForwarder(final HttpClient httpClient) { + this.httpClient = httpClient; + this.objectMapper = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + } + + @Override + public ReportIssueForwardResponse forward(final ReportIssueForwardRequest request) + throws IOException, InterruptedException { + + final HttpRequest httpRequest = request.screenshot().isPresent() + ? buildMultipartRequest(request) + : buildJsonRequest(request); + + final HttpResponse response = this.httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString()); + + return new ReportIssueForwardResponse( + response.statusCode(), + response.body(), + response.headers().firstValue("Content-Type")); + } + + private HttpRequest buildJsonRequest(final ReportIssueForwardRequest request) throws IOException { + final Map body = Map.of("contentlet", request.contentlet()); + return HttpRequest.newBuilder() + .uri(request.upstreamUri()) + .timeout(Duration.ofSeconds(60)) + .header("Content-Type", MediaType.APPLICATION_JSON) + .POST(HttpRequest.BodyPublishers.ofString(this.objectMapper.writeValueAsString(body))) + .build(); + } + + private HttpRequest buildMultipartRequest(final ReportIssueForwardRequest request) throws IOException { + final String boundary = "dotcms-report-issue-" + UUID.randomUUID(); + final byte[] body = buildMultipartBody(boundary, request); + + // PUT is required by the upstream dotCMS workflow fire endpoint for multipart + // submissions (binary fields). The JSON branch above uses POST. + return HttpRequest.newBuilder() + .uri(request.upstreamUri()) + .timeout(Duration.ofSeconds(60)) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .PUT(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + } + + private byte[] buildMultipartBody(final String boundary, final ReportIssueForwardRequest request) + throws IOException { + + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final String lineBreak = "\r\n"; + final ReportIssueScreenshot screenshot = request.screenshot().orElseThrow(); + + final Map jsonBody = new LinkedHashMap<>(); + jsonBody.put("contentlet", request.contentlet()); + jsonBody.put("binaryFields", request.binaryFields()); + + outputStream.write(("--" + boundary + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(("Content-Disposition: form-data; name=\"json\"" + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(("Content-Type: application/json" + lineBreak + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(this.objectMapper.writeValueAsBytes(jsonBody)); + outputStream.write(lineBreak.getBytes(StandardCharsets.UTF_8)); + + outputStream.write(("--" + boundary + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + + escapeHeaderValue(screenshot.fileName()) + "\"" + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(("Content-Type: " + screenshot.mediaType() + lineBreak + lineBreak).getBytes(StandardCharsets.UTF_8)); + outputStream.write(screenshot.bytes()); + outputStream.write(lineBreak.getBytes(StandardCharsets.UTF_8)); + + outputStream.write(("--" + boundary + "--" + lineBreak).getBytes(StandardCharsets.UTF_8)); + + return outputStream.toByteArray(); + } + + private String escapeHeaderValue(final String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} + +final class BrowserDescriptor { + private final String browser; + + BrowserDescriptor(final String browser) { + this.browser = browser; + } + + static BrowserDescriptor empty() { + return new BrowserDescriptor(null); + } + + boolean isPresent() { + return UtilMethods.isSet(browser); + } + + String browser() { + return browser; + } +} + +final class BrowserPattern { + private final String token; + private final String browser; + + BrowserPattern(final String token, final String browser) { + this.token = token; + this.browser = browser; + } + + String token() { + return token; + } + + String browser() { + return browser; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index 1b08aa05ed3b..dc8dc61f8c0f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -58,6 +58,7 @@ public class ConfigurationResource implements Serializable { private final ConfigurationHelper helper; + private static final String REPORT_ISSUE_INCLUDE_USER_PII = "REPORT_ISSUE_INCLUDE_USER_PII"; private static final Set WHITE_LIST = ImmutableSet.copyOf( Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST", @@ -70,7 +71,8 @@ public class ConfigurationResource implements Serializable { FeatureFlagName.FEATURE_FLAG_PAGE_SCANNER, PageScannerResource.API_URL_PROPERTY, FeatureFlagName.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION, - FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR })); + FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR, + "REPORT_ISSUE_INCLUDE_USER_PII" })); private boolean isOnBlackList(final String key) { return null != JVMInfoResource.obfuscatePattern ? JVMInfoResource.obfuscatePattern.matcher(key).find() : false; @@ -137,8 +139,9 @@ private Object recoveryFromConfig (final String key) { return Arrays.asList(Config.getStringArrayProperty(key.replace("list:", StringPool.BLANK), new String[]{})); } else if(key.startsWith("boolean:")) { - - return Config.getBooleanProperty(key.replace("boolean:", StringPool.BLANK), false); + final String propertyKey = key.replace("boolean:", StringPool.BLANK); + final boolean defaultValue = REPORT_ISSUE_INCLUDE_USER_PII.equals(propertyKey); + return Config.getBooleanProperty(propertyKey, defaultValue); } else if (key.startsWith("number:")) { return Config.getIntProperty(key.replace("number:", StringPool.BLANK), 0); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3be349e7e09b..68651b08b97d 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -3041,6 +3041,15 @@ Multiple-Files=Multiple Files must-be-type=must be type Must-specify-each-hours-and/or-minutes-with-a-value-bigger-than-0=You must specify hours and/or minutes with a value greater than 0. my-account=My Account +report-an-issue=Report an Issue +report-an-issue.description=Description +report-an-issue.screenshot=Screenshot (optional) +report-an-issue.screenshot.invalid-type=Screenshot must be a PNG, JPEG, or WebP image +report-an-issue.screenshot.max-size=Screenshot must be 10 MB or smaller +report-an-issue.error=Unable to submit the issue report +report-an-issue.success=Issue report submitted successfully +report-an-issue.anonymous=Send anonymously (do not include my name or email) +report-an-issue.anonymous.enforced=Anonymous submission is enforced by your administrator. N-A=N/A Name-change-failed=Name change failed. Name-changed=Name changed diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 19c2d8134b1f..d9d49f275be9 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -14414,6 +14414,36 @@ paths: description: default response tags: - Relationships + /v1/report-issue: + post: + description: Creates a Bug contentlet in the configured upstream reporting dotCMS + instance. + operationId: reportIssue + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/FormDataMultiPart" + responses: + "200": + content: + application/json: {} + description: Issue reported + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityView" + description: Invalid report payload + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityView" + description: Reporting service unavailable or unauthorized + summary: Report a dotCMS UI issue + tags: + - Workflow /v1/roles: get: description: Loads the root roles with optional children roles diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResourceTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResourceTest.java new file mode 100644 index 000000000000..a168a32942cf --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/reportissue/ReportIssueResourceTest.java @@ -0,0 +1,429 @@ +package com.dotcms.rest.api.v1.reportissue; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.rest.ErrorEntity; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotmarketing.util.Config; +import com.liferay.portal.model.User; +import com.liferay.portal.util.ReleaseInfo; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class ReportIssueResourceTest { + + private WebResource webResource; + private CapturingForwarder forwarder; + private ReportIssueResource resource; + private HttpServletRequest request; + private HttpServletResponse response; + private User user; + + @BeforeEach + void setUp() { + webResource = mock(WebResource.class); + forwarder = new CapturingForwarder(); + resource = new ReportIssueResource(webResource, forwarder); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + user = mock(User.class); + + final InitDataObject initDataObject = mock(InitDataObject.class); + when(initDataObject.getUser()).thenReturn(user); + when(webResource.init(any(WebResource.InitBuilder.class))).thenReturn(initDataObject); + + when(user.getUserId()).thenReturn("user-1"); + when(user.getEmailAddress()).thenReturn("user@example.com"); + when(user.getFullName()).thenReturn("Test User"); + when(request.getHeader("User-Agent")).thenReturn("Chrome 120"); + when(request.getHeader("Referer")).thenReturn("https://example.com/admin"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getServerName()).thenReturn("example.com"); + when(request.getRequestURI()).thenReturn("/api/v1/report-issue"); + when(request.getRequestURL()).thenReturn(new StringBuffer("https://example.com/dotAdmin")); + } + + @AfterEach + void tearDown() { + Config.setProperty(ReportIssueResource.WORKFLOW_URL_PROPERTY, null); + Config.setProperty("REPORT_ISSUE_SCREENSHOT_MAX_BYTES", null); + Config.setProperty(ReportIssueResource.REPORT_ISSUE_INCLUDE_USER_PII_PROPERTY, null); + } + + @Test + void reportIssue_descriptionOnly_forwardsBugContentlet() { + final Response response = resource.reportIssue(request, this.response, + formWithDescription("Login button is broken\nOn Safari")); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("https://corpsites-headless.dotcms.cloud/api/v1/workflow/actions/default/fire/NEW", + forwarder.request.upstreamUri().toString()); + assertEquals("Bug", forwarder.request.contentlet().get("contentType")); + assertEquals("/api/v1/report-issue [" + ReleaseInfo.getVersion() + "]", + forwarder.request.contentlet().get("title")); + assertEquals("Login button is broken\nOn Safari", forwarder.request.contentlet().get("description")); + assertFalse(forwarder.request.contentlet().containsKey("binaryFields")); + assertTrue(forwarder.request.screenshot().isEmpty()); + assertTrue(forwarder.request.binaryFields().isEmpty()); + + final Map metadata = metadata(); + assertEquals(ReleaseInfo.getVersion(), metadata.get("dotcmsVersion")); + assertEquals(ReleaseInfo.getBuildDateString(), metadata.get("dotcmsBuildDate")); + assertEquals("Chrome 120", metadata.get("userAgent")); + assertEquals("https://example.com/admin", metadata.get("referer")); + assertEquals("https://example.com/dotAdmin", metadata.get("requestUrl")); + assertEquals("example.com", metadata.get("serverName")); + assertEquals(Boolean.FALSE, metadata.get("anonymous")); + assertTrue(metadata.containsKey("user")); + } + + @Test + void reportIssue_anonymousFlag_stripsUserIdentity() { + final FormDataMultiPart multipart = formWithDescription("Editor panel overlaps") + .field("anonymous", "true"); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + final Map metadata = metadata(); + assertEquals(Boolean.TRUE, metadata.get("anonymous")); + assertFalse(metadata.containsKey("user"), + "user identity must be stripped when anonymous flag is true"); + } + + @Test + void reportIssue_operatorOptOut_stripsUserIdentityRegardlessOfFlag() { + Config.setProperty(ReportIssueResource.REPORT_ISSUE_INCLUDE_USER_PII_PROPERTY, "false"); + final FormDataMultiPart multipart = formWithDescription("Editor panel overlaps") + .field("anonymous", "false"); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + final Map metadata = metadata(); + assertEquals(Boolean.TRUE, metadata.get("anonymous")); + assertFalse(metadata.containsKey("user"), + "operator opt-out must override the user's anonymous flag"); + } + + @Test + void reportIssue_withScreenshot_forwardsMultipartFields() { + final byte[] screenshot = "fake-png".getBytes(); + final FormDataMultiPart multipart = formWithDescription("Editor panel overlaps"); + multipart.field("metadata", "{\"browser\":\"Safari 17\",\"url\":\"https://example.com/dotAdmin/#/content\",\"viewport\":\"1440x900\"}"); + multipart.bodyPart(new StreamDataBodyPart( + "screenshot", + new ByteArrayInputStream(screenshot), + "screenshot.png", + MediaType.valueOf("image/png"))); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("/content [" + ReleaseInfo.getVersion() + " - Safari]", + forwarder.request.contentlet().get("title")); + assertFalse(forwarder.request.contentlet().containsKey("binaryFields")); + assertEquals(List.of("screenshot"), forwarder.request.binaryFields()); + assertTrue(forwarder.request.screenshot().isPresent()); + assertEquals("image/png", forwarder.request.screenshot().get().mediaType()); + assertArrayEquals(screenshot, forwarder.request.screenshot().get().bytes()); + + final Map metadata = metadata(); + assertEquals(ReleaseInfo.getVersion(), metadata.get("dotcmsVersion")); + assertEquals(ReleaseInfo.getBuildDateString(), metadata.get("dotcmsBuildDate")); + final Map clientMetadata = clientMetadata(metadata); + assertEquals("Safari 17", clientMetadata.get("browser")); + assertEquals("https://example.com/dotAdmin/#/content", clientMetadata.get("url")); + assertEquals("1440x900", clientMetadata.get("viewport")); + } + + @Test + void reportIssue_withScreenshot_closesScreenshotStream() { + final CloseTrackingInputStream screenshot = new CloseTrackingInputStream("fake-png".getBytes()); + final FormDataMultiPart multipart = formWithDescription("Editor panel overlaps"); + multipart.bodyPart(new StreamDataBodyPart( + "screenshot", + screenshot, + "screenshot.png", + MediaType.valueOf("image/png"))); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertTrue(screenshot.closed); + } + + @Test + void reportIssue_withUserAgentMetadata_buildsTitleFromPathAndParsedBrowser() { + final FormDataMultiPart multipart = formWithDescription("Editor panel overlaps"); + multipart.field("metadata", "{\"browser\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15\",\"url\":\"https://example.com/dotAdmin/#/pages\"}"); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("/pages [" + ReleaseInfo.getVersion() + " - Safari]", + forwarder.request.contentlet().get("title")); + } + + @Test + void reportIssue_blankDescription_returns400() { + final Response response = resource.reportIssue(request, this.response, formWithDescription(" ")); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_INVALID_REQUEST, firstError(response).getErrorCode()); + } + + @Test + void reportIssue_invalidScreenshotType_returns400() { + final FormDataMultiPart multipart = formWithDescription("Broken screen"); + multipart.bodyPart(new FormDataBodyPart( + "screenshot", + new ByteArrayInputStream("text".getBytes()), + MediaType.TEXT_PLAIN_TYPE)); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_INVALID_REQUEST, firstError(response).getErrorCode()); + } + + @Test + void reportIssue_oversizedScreenshot_returns400() { + Config.setProperty("REPORT_ISSUE_SCREENSHOT_MAX_BYTES", "3"); + final FormDataMultiPart multipart = formWithDescription("Broken screen"); + multipart.bodyPart(new StreamDataBodyPart( + "screenshot", + new ByteArrayInputStream("too-large".getBytes()), + "screenshot.png", + MediaType.valueOf("image/png"))); + + final Response response = resource.reportIssue(request, this.response, multipart); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_INVALID_REQUEST, firstError(response).getErrorCode()); + } + + @Test + void reportIssue_upstream401_returnsProxyNotAuthorized502() { + forwarder.response = new ReportIssueForwardResponse( + Response.Status.UNAUTHORIZED.getStatusCode(), + "", + Optional.of(MediaType.APPLICATION_JSON)); + + final Response response = resource.reportIssue(request, this.response, + formWithDescription("Unable to save page")); + + assertEquals(Response.Status.BAD_GATEWAY.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_PROXY_NOT_AUTHORIZED, firstError(response).getErrorCode()); + } + + @Test + void reportIssue_upstream403_returnsProxyNotAuthorized502() { + forwarder.response = new ReportIssueForwardResponse( + Response.Status.FORBIDDEN.getStatusCode(), + "", + Optional.of(MediaType.APPLICATION_JSON)); + + final Response response = resource.reportIssue(request, this.response, + formWithDescription("Unable to save page")); + + assertEquals(Response.Status.BAD_GATEWAY.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_PROXY_NOT_AUTHORIZED, firstError(response).getErrorCode()); + } + + @Test + void reportIssue_networkFailure_returnsServiceUnavailable502() { + forwarder.exception = new IOException("Connection refused"); + + final Response response = resource.reportIssue(request, this.response, + formWithDescription("Unable to save page")); + + assertEquals(Response.Status.BAD_GATEWAY.getStatusCode(), response.getStatus()); + assertEquals(ReportIssueResource.ERROR_SERVICE_UNAVAILABLE, firstError(response).getErrorCode()); + } + + @Test + @SuppressWarnings("unchecked") + void httpForwarder_doesNotSendAuthorizationHeader() throws Exception { + final HttpClient httpClient = mock(HttpClient.class); + final HttpResponse httpResponse = mock(HttpResponse.class); + when(httpResponse.statusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.body()).thenReturn("{}"); + when(httpResponse.headers()).thenReturn(java.net.http.HttpHeaders.of(Map.of(), (name, value) -> true)); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(httpResponse); + + final HttpReportIssueForwarder httpForwarder = new HttpReportIssueForwarder(httpClient); + httpForwarder.forward(new ReportIssueForwardRequest( + URI.create("https://corpsites.example.com/api/v1/workflow/actions/default/fire/NEW"), + Map.of("contentType", "Bug", "title", "Title", "description", "Description"), + Optional.empty(), + List.of())); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + org.mockito.Mockito.verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + assertTrue(requestCaptor.getValue().headers().firstValue("Authorization").isEmpty()); + assertEquals("POST", requestCaptor.getValue().method()); + assertEquals("application/json", + requestCaptor.getValue().headers().firstValue("Content-Type").orElse("")); + } + + @Test + @SuppressWarnings("unchecked") + void httpForwarder_withScreenshot_sendsMultipartJsonAndFileWithoutAuthorization() throws Exception { + final HttpClient httpClient = mock(HttpClient.class); + final HttpResponse httpResponse = mock(HttpResponse.class); + when(httpResponse.statusCode()).thenReturn(Response.Status.OK.getStatusCode()); + when(httpResponse.body()).thenReturn("{}"); + when(httpResponse.headers()).thenReturn(java.net.http.HttpHeaders.of(Map.of(), (name, value) -> true)); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(httpResponse); + + final HttpReportIssueForwarder httpForwarder = new HttpReportIssueForwarder(httpClient); + httpForwarder.forward(new ReportIssueForwardRequest( + URI.create("https://corpsites.example.com/api/v1/workflow/actions/default/fire/NEW"), + Map.of("contentType", "Bug", "title", "Title", "description", "Description"), + Optional.of(new ReportIssueScreenshot("screen.png", "image/png", "fake-png".getBytes())), + List.of("screenshot"))); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + org.mockito.Mockito.verify(httpClient).send(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + final HttpRequest capturedRequest = requestCaptor.getValue(); + assertTrue(capturedRequest.headers().firstValue("Authorization").isEmpty()); + assertEquals("PUT", capturedRequest.method()); + assertTrue(capturedRequest.headers().firstValue("Content-Type").orElse("") + .startsWith("multipart/form-data; boundary=dotcms-report-issue-")); + + final String body = bodyAsString(capturedRequest); + assertTrue(body.contains("Content-Disposition: form-data; name=\"json\"")); + assertTrue(body.contains("\"contentlet\"")); + assertTrue(body.contains("\"binaryFields\":[\"screenshot\"]")); + assertTrue(body.contains("\"contentType\":\"Bug\"")); + assertTrue(body.contains("Content-Disposition: form-data; name=\"file\"; filename=\"screen.png\"")); + assertTrue(body.contains("Content-Type: image/png")); + assertTrue(body.contains("fake-png")); + } + + private FormDataMultiPart formWithDescription(final String description) { + return new FormDataMultiPart().field("description", description); + } + + @SuppressWarnings("unchecked") + private Map metadata() { + return (Map) forwarder.request.contentlet().get("metadata"); + } + + @SuppressWarnings("unchecked") + private Map clientMetadata(final Map metadata) { + final Object client = metadata.get("client"); + return client instanceof Map ? (Map) client : Map.of(); + } + + private ErrorEntity firstError(final Response response) { + return ((ResponseEntityView) response.getEntity()).getErrors().get(0); + } + + private String bodyAsString(final HttpRequest request) throws Exception { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final CountDownLatch completed = new CountDownLatch(1); + final AtomicReference error = new AtomicReference<>(); + + request.bodyPublisher().orElseThrow().subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(final Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(final ByteBuffer item) { + final byte[] bytes = new byte[item.remaining()]; + item.get(bytes); + outputStream.write(bytes, 0, bytes.length); + } + + @Override + public void onError(final Throwable throwable) { + error.set(throwable); + completed.countDown(); + } + + @Override + public void onComplete() { + completed.countDown(); + } + }); + + assertTrue(completed.await(1, TimeUnit.SECONDS)); + if (error.get() != null) { + throw new IOException(error.get()); + } + + return outputStream.toString(StandardCharsets.UTF_8); + } + + private static class CapturingForwarder implements ReportIssueForwarder { + private ReportIssueForwardRequest request; + private ReportIssueForwardResponse response = new ReportIssueForwardResponse( + Response.Status.OK.getStatusCode(), + "{}", + Optional.of(MediaType.APPLICATION_JSON)); + private IOException exception; + + @Override + public ReportIssueForwardResponse forward(final ReportIssueForwardRequest request) throws IOException { + this.request = request; + if (exception != null) { + throw exception; + } + return response; + } + } + + private static class CloseTrackingInputStream extends ByteArrayInputStream { + private boolean closed; + + private CloseTrackingInputStream(final byte[] bytes) { + super(bytes); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } +}