diff --git a/lib/GADS/API.pm b/lib/GADS/API.pm index c0a92b940..d05fd5a44 100644 --- a/lib/GADS/API.pm +++ b/lib/GADS/API.pm @@ -1461,6 +1461,21 @@ get '/api/get_key' => require_login sub { } }; +post '/api/script_error' => require_login sub { + my $user = logged_in_user; + my $body = _decode_json_body(); + + my $logger = GADS::Audit->new( + user => $user, + schema => schema, + ); + + $logger->script_error(%$body); + + content_type 'application/json; charset=UTF-8'; + return success "Script error logged successfully"; +}; + sub _success { my $msg = shift; send_as JSON => { @@ -1476,7 +1491,7 @@ sub _decode_json_body if request->content_type ne 'application/json'; my $body = try { decode_json request->body } - or error __"Failed to decode JSON"; + or error __"Failed to decode JSON" . " - " . $@; $body; } diff --git a/lib/GADS/Audit.pm b/lib/GADS/Audit.pm index f2da65450..946913503 100644 --- a/lib/GADS/Audit.pm +++ b/lib/GADS/Audit.pm @@ -70,7 +70,26 @@ has filtering => ( builder => sub { +{} }, ); -sub audit_types{ [qw/user_action login_change login_success logout login_failure/] }; +sub audit_types{ [qw/user_action login_change login_success logout login_failure script_error/] }; + +sub script_error +{ + my ($self, %options) = @_; + + my $description = $options{description} || 'Script error'; + my $method = $options{method} || 'unknown'; + my $url = $options{url} || 'unknown'; + + $self->schema->resultset('Audit')->create({ + user_id => $self->user_id, + description => $description, + type => 'script_error', + method => $method, + url => $url, + datetime => DateTime->now, + instance_id => $options{instance_id}, + }); +} sub user_action { my ($self, %options) = @_; diff --git a/src/frontend/js/lib/logging.js b/src/frontend/js/lib/logging.js index bea4144c4..4799b610a 100644 --- a/src/frontend/js/lib/logging.js +++ b/src/frontend/js/lib/logging.js @@ -1,34 +1,62 @@ +import { uploadMessage } from "util/scriptErrorHandler"; + class Logging { - constructor() { - this.allowLogging = - window.test || - location.hostname === 'localhost' || - location.hostname === '127.0.0.1' || - location.hostname.endsWith('.peek.digitpaint.nl') + constructor() { + this.allowLogging = + window.test || + location.hostname === 'localhost' || + location.hostname === '127.0.0.1' || + location.hostname.endsWith('.peek.digitpaint.nl') } log(...message) { - if(this.allowLogging) { - console.log(message) - } + if (this.allowLogging) { + console.log(message) + } else { + const message = this.formatMessage('log', ...message) + uploadMessage(message) + } } info(...message) { - if(this.allowLogging) { - console.info(message) - } + if (this.allowLogging) { + console.info(message) + } else { + const message = this.formatMessage('info', ...message) + uploadMessage(message) + } } warn(...message) { - if(this.allowLogging) { - console.warn(message) - } + if (this.allowLogging) { + console.warn(message) + } else { + const message = this.formatMessage('warn', ...message) + uploadMessage(message) + } } error(...message) { - if(this.allowLogging) { - console.error(message) + if (this.allowLogging) { + console.error(message) + } else { + const message = this.formatMessage('error', ...message) + uploadMessage(message) + } + } + + formatMessage(type, ...message) { + let output = type + ': '; + for (let i = 0; i < message.length; i++) { + if (typeof message[i] === 'object') { + output += JSON.stringify(message[i]); + } else { + // This is wrapped so that anything that's not an object is converted to a string + output += `${message[i]}`; } + if (i < message.length - 1) output += ' '; + } + return output; } } diff --git a/src/frontend/js/lib/util/scriptErrorHandler/index.ts b/src/frontend/js/lib/util/scriptErrorHandler/index.ts new file mode 100644 index 000000000..4a8b389db --- /dev/null +++ b/src/frontend/js/lib/util/scriptErrorHandler/index.ts @@ -0,0 +1,34 @@ +import { logging } from "logging"; +import { uploadMessage } from "./lib/MessageUploader"; + +const createErrorString = (message: string, source: any, lineno: number, colno: number, error: Error | string | null) => { + let errorString = `Error: ${message}\nSource: ${source}\nLine: ${lineno}, Column: ${colno}`; + if (error && (error as Error)?.stack) { + errorString += `\nStack: ${(error as Error)?.stack}`; + } + return errorString; +} + +window.onerror = function (message: string, source: any, lineno: number, colno: number, error: Error | string | null) { + if (location.host === 'localhost') { + // If we're on localhost, we log the error to the console. This is useful for development. + logging.error("Script error occurred:", message, source, lineno, colno, error); + return; + } + if (location.pathname === '/api/script_error' || location.pathname === '/login') { + // If we're on the script error page, we don't want to log it again. + console.error("Script error occurred but not logged to avoid recursion."); + console.error(createErrorString(message, source, lineno, colno, error)); + return; + } + const description = createErrorString(message, source, lineno, colno, error) + console.log("Script error occurred:", description); + const method = 'N/A'; + + uploadMessage(description, method) + .catch(err => { + console.error("Failed to upload script error:", err); + }); +} + +export { uploadMessage }; \ No newline at end of file diff --git a/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.test.ts b/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.test.ts new file mode 100644 index 000000000..ad82189bd --- /dev/null +++ b/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { MessageUploader } from './MessageUploader'; + +describe('MessageUploader', () => { + class MockUploader { + upload = jest.fn(); + } + + it('should upload messages correctly', async () => { + const uploader = new MockUploader(); + const messageUploader = new MessageUploader(uploader); + + const messages = { id: 1, content: 'Test message 1' }; + + await messageUploader.uploadMessage(JSON.stringify(messages)); + + expect(uploader.upload).toHaveBeenCalledTimes(1); + expect(uploader.upload).toHaveBeenCalledWith({ + description: JSON.stringify(messages), + method: 'N/A', + url: 'http://localhost/' + }); + }); +}); \ No newline at end of file diff --git a/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.ts b/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.ts new file mode 100644 index 000000000..bc3a88952 --- /dev/null +++ b/src/frontend/js/lib/util/scriptErrorHandler/lib/MessageUploader.ts @@ -0,0 +1,33 @@ +import { Uploader } from "util/upload/UploadControl"; + +export const uploadMessage = async (message: string, method?: string) => { + const body = { + description: message, + method: method || 'N/A', + url: window.location.href + }; + const uploader = new Uploader('/api/script_error', 'POST'); + const messageUploader = new MessageUploader(uploader); + return await messageUploader.uploadMessage(body.description, body.method); +}; + +export class MessageUploader { + constructor(private uploader: Uploader) { + } + + async uploadMessage(description: string, method?: string): Promise { + method ||= 'N/A'; + const csrf_token = document.body.dataset.csrf; + const body = { + description, + method, + url: window.location.href, + csrf_token + }; + try { + return await this.uploader.upload(body); + } catch (err) { + console.error("Failed to upload message:", err); + } + } +} \ No newline at end of file diff --git a/src/frontend/js/site.js b/src/frontend/js/site.js index 1517accdf..e93f46bbc 100644 --- a/src/frontend/js/site.js +++ b/src/frontend/js/site.js @@ -4,6 +4,7 @@ import 'bootstrap'; import 'components/graph/lib/chart'; import 'util/filedrag'; import 'util/actionsHandler'; +import 'util/scriptErrorHandler'; // Components import AddTableModalComponent from 'components/modal/modals/new-table'; @@ -87,4 +88,4 @@ registerComponent(AutosaveComponent); // Initialize all components at some point initializeRegisteredComponents(document.body); -handleActions(); \ No newline at end of file +handleActions();