From a9c642a116f4a4ec2dbe170bf6c9263996db1abd Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Wed, 16 Nov 2022 18:15:03 +0100 Subject: [PATCH] CSP: Extract RfC JS from view to assets Relates to CODEOCEAN-CP --- .../javascripts/request_for_comments.js | 391 ++++++++++++++++++ app/views/request_for_comments/show.html.slim | 386 ----------------- config/locales/de.yml | 4 +- config/locales/en.yml | 4 +- 4 files changed, 395 insertions(+), 390 deletions(-) create mode 100644 app/assets/javascripts/request_for_comments.js diff --git a/app/assets/javascripts/request_for_comments.js b/app/assets/javascripts/request_for_comments.js new file mode 100644 index 00000000..d999bcaa --- /dev/null +++ b/app/assets/javascripts/request_for_comments.js @@ -0,0 +1,391 @@ +$(document).on('turbolinks:load', function () { + const exerciseCaption = $('#exercise_caption'); + + if (!$.isController('request_for_comments') || !exerciseCaption.isPresent()) { + return; + } + + $('.modal-content').draggable({ + handle: '.modal-header' + }).resizable({ + autoHide: true + }); + + const solvedButton = $('#mark-as-solved-button'); + const thankYouContainer = $('#thank-you-container'); + const rfcId = exerciseCaption.data('rfc-id'); + + solvedButton.on('click', function () { + $.ajax({ + dataType: 'json', + method: 'GET', + url: Routes.mark_as_solved_request_for_comment_path(rfcId), + }).done(function (response) { + if (response.solved) { + solvedButton.removeClass('btn-primary'); + solvedButton.addClass('btn-success'); + solvedButton.html(I18n.t('request_for_comments.solved')); + solvedButton.off('click'); + thankYouContainer.show(); + } + }); + }); + + $('#send-thank-you-note').on('click', function () { + const value = $('#thank-you-note').val(); + if (value) { + $.ajax({ + dataType: 'json', + method: 'POST', + url: Routes.set_thank_you_note_request_for_comment_path(rfcId), + data: { + note: value + } + }).done(function () { + thankYouContainer.hide(); + }); + } + }); + + $('#cancel-thank-you-note').on('click', function () { + thankYouContainer.hide(); + }); + + $('.text > .collapse-button').on('click', function (_event) { + $(this).toggleClass('fa-chevron-down'); + $(this).toggleClass('fa-chevron-up'); + $(this).parent().toggleClass('collapsed'); + }); + +// set file paths for ace + _.each(['modePath', 'themePath', 'workerPath'], function (attribute) { + ace.config.set(attribute, CodeOceanEditor.ACE_FILES_PATH); + }); + + const commentitor = $('.editor'); + + commentitor.each(function (index, editor) { + const currentEditor = ace.edit(editor); + currentEditor.setReadOnly(true); + // set editor mode (used for syntax highlighting + currentEditor.getSession().setMode($(editor).data('mode')); + currentEditor.getSession().setOption("useWorker", false); + + currentEditor.commentVisualsByLine = {}; + setAnnotations(currentEditor, $(editor).data('file-id')); + currentEditor.on("guttermousedown", handleSidebarClick); + currentEditor.on("guttermousemove", showPopover); + }); + + function preprocess(commentText) { + // sanitize comments to deal with XSS attacks: + commentText = $('div.sanitizer').text(commentText).html(); + // display original line breaks: + return commentText.replace(/\n/g, '
'); + } + + function replaceNewlineTags(commentText) { + // display original line breaks as \n: + return commentText.replace(/
/g, '\n'); + } + + function generateCommentHtmlContent(comments) { + let htmlContent = ''; + comments.forEach(function (comment, index) { + const commentText = preprocess(comment.text); + if (index !== 0) { + htmlContent += '
' + } + htmlContent += '\ +
\ +
\ +
' + preprocess(comment.username) + '
\ +
' + comment.date + '
\ +
\ + \ + ' + I18n.t('request_for_comments.comment_edited') + ' \ +
\ +
\ +
' + commentText + '
\ + \ +
\ + \ + \ +
\ +
'; + }); + return htmlContent; + } + + function buildPopover(comments, where) { + // only display the newest three comments in preview + const maxComments = 3; + let htmlContent = generateCommentHtmlContent(comments.reverse().slice(0, maxComments)); + if (comments.length > maxComments) { + // add a hint that there are more comments than shown here + htmlContent += ''; + } + where.popover({ + content: htmlContent, + html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing) + trigger: 'manual', // can only be triggered by $(where).popover('show' | 'hide') + container: 'body' + }); + } + + function setAnnotations(editor, fileid) { + const session = editor.getSession(); + + const jqrequest = $.ajax({ + dataType: 'json', + method: 'GET', + url: Routes.comments_path(), + data: { + file_id: fileid + } + }); + + jqrequest.done(function (response) { + $.each(response, function (index, comment) { + comment.className = 'code-ocean_comment'; + }); + session.setAnnotations(response); + }); + } + + function getCommentsForRow(editor, row) { + return editor.getSession().getAnnotations().filter(function (element) { + return element.row === row; + }) + } + + function deleteComment(commentId, editor, file_id, callback) { + const jqxhr = $.ajax({ + type: 'DELETE', + url: Routes.comment_path(commentId) + }); + jqxhr.done(function () { + setAnnotations(editor, file_id); + callback(); + }); + jqxhr.fail(ajaxError); + } + + function updateComment(commentId, text, editor, file_id, callback) { + const jqxhr = $.ajax({ + type: 'PATCH', + url: Routes.comment_path(commentId), + data: { + comment: { + text: text + } + } + }); + jqxhr.done(function () { + setAnnotations(editor, file_id); + callback(); + }); + jqxhr.fail(ajaxError); + } + + function createComment(file_id, row, editor, commenttext) { + const jqxhr = $.ajax({ + data: { + comment: { + file_id: file_id, + row: row, + column: 0, + text: commenttext, + request_id: $('h4#exercise_caption').data('rfc-id') + } + }, + dataType: 'json', + method: 'POST', + url: Routes.comments_path() + }); + jqxhr.done(function () { + setAnnotations(editor, file_id); + }); + jqxhr.fail(ajaxError); + } + + function subscribeToRFC(subscriptionType, checkbox) { + checkbox.attr("disabled", true); + const jqxhr = $.ajax({ + data: { + subscription: { + request_for_comment_id: $('h4#exercise_caption').data('rfc-id'), + subscription_type: subscriptionType + } + }, + dataType: 'json', + method: 'POST', + url: Routes.subscriptions_path({format: 'json'}) + }); + jqxhr.done(function (subscription) { + checkbox.data('subscription', subscription.id); + checkbox.attr("disabled", false); + }); + jqxhr.fail(function (response) { + checkbox.prop('checked', false); + checkbox.attr("disabled", false); + ajaxError(response); + }); + } + + function unsubscribeFromRFC(checkbox) { + checkbox.attr("disabled", true); + const subscriptionId = checkbox.data('subscription'); + const jqxhr = $.ajax({ + url: Routes.unsubscribe_subscription_path(subscriptionId, {format: 'json'}) + }); + jqxhr.done(function (response) { + checkbox.prop('checked', false); + checkbox.data('subscription', null); + checkbox.attr("disabled", false); + $.flash.success({text: response.message}); + }); + jqxhr.fail(function (response) { + checkbox.prop('checked', true); + checkbox.attr("disabled", false); + ajaxError(response); + }); + } + + let lastRow = null; + let lastTarget = null; + + function showPopover(e) { + const target = e.domEvent.target; + const row = e.getDocumentPosition().row; + + if (target.className.indexOf('ace_gutter-cell') === -1 || lastRow === row) { + return; + } + if (lastTarget === target) { + // sometimes the row gets updated before the DOM event target, so we need to wait for it to change + return; + } + lastRow = row; + + const editor = e.editor; + const comments = getCommentsForRow(editor, row); + buildPopover(comments, $(target)); + lastTarget = target; + + $(target).popover('show'); + $(target).on('mouseleave', function () { + $(this).off('mouseleave'); + $(this).popover('dispose'); + }); + } + + $('.ace_gutter').on('mouseleave', function () { + lastRow = null; + lastTarget = null; + }); + + function handleSidebarClick(e) { + const target = e.domEvent.target; + if (target.className.indexOf('ace_gutter-cell') === -1) return; + + const editor = e.editor; + const fileid = $(editor.container).data('file-id'); + + const row = e.getDocumentPosition().row; + e.stop(); + $('.modal-title').text(I18n.t('request_for_comments.modal_title', {line: row + 1})); + + const commentModal = $('#comment-modal'); + + const otherComments = commentModal.find('#otherComments'); + const htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row)); + if (htmlContent) { + otherComments.show(); + const container = otherComments.find('.container'); + container.html(htmlContent); + + const deleteButtons = container.find('.action-delete'); + deleteButtons.on('click', function (event) { + const button = $(event.target); + const parent = $(button).parent().parent(); + const commentId = parent.data('comment-id'); + + deleteComment(commentId, editor, fileid, function () { + parent.html('
' + I18n.t('comments.deleted') + '
'); + }); + }); + + const editButtons = container.find('.action-edit'); + editButtons.on('click', function (event) { + const button = $(event.target); + const parent = $(button).parent().parent(); + const commentId = parent.data('comment-id'); + const currentlyEditing = button.data('editing'); + + const deleteButton = parent.find('.action-delete'); + const commentContent = parent.find('.comment-content'); + const commentEditor = parent.find('textarea.comment-editor'); + const commentUpdated = parent.find('.comment-updated'); + + if (currentlyEditing) { + updateComment(commentId, commentEditor.val(), editor, fileid, function () { + button.text(I18n.t('shared.edit')); + button.data('editing', false); + commentContent.html(preprocess(commentEditor.val())); + deleteButton.show(); + commentContent.show(); + commentEditor.hide(); + commentUpdated.removeClass('d-none'); + }); + } else { + button.text(I18n.t('comments.save_update')); + button.data('editing', true); + deleteButton.hide(); + commentContent.hide(); + commentEditor.val(replaceNewlineTags(commentEditor.val())); + commentEditor.show(); + } + }); + } else { + otherComments.hide(); + } + + const subscribeCheckbox = commentModal.find('#subscribe'); + subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription')); + subscribeCheckbox.off('change'); + subscribeCheckbox.on('change', function () { + if (this.checked) { + subscribeToRFC('author', $(this)); + } else { + unsubscribeFromRFC($(this)); + } + }); + + const addCommentButton = commentModal.find('#addCommentButton'); + addCommentButton.off('click'); + addCommentButton.on('click', function () { + const commentTextarea = commentModal.find('#myComment > textarea'); + const commenttext = commentTextarea.val(); + if (commenttext !== "") { + createComment(fileid, row, editor, commenttext); + commentTextarea.val(''); + bootstrap.Modal.getInstance(commentModal).hide(); + } + }); + + new bootstrap.Modal(commentModal).show(); + } + + function ajaxError(response) { + const responseJSON = ((response || {}).responseJSON || {}); + const message = responseJSON.message || responseJSON.error || ''; + + $.flash.danger({ + text: message.length > 0 ? message : $('#flash').data('message-failure'), + showPermanent: response.status === 422, + }); + } +}); diff --git a/app/views/request_for_comments/show.html.slim b/app/views/request_for_comments/show.html.slim index f72a854c..f39dff79 100644 --- a/app/views/request_for_comments/show.html.slim +++ b/app/views/request_for_comments/show.html.slim @@ -78,389 +78,3 @@ = file.content = render('shared/modal', id: 'comment-modal', title: t('exercises.implement.comment.dialogtitle'), template: 'exercises/_comment_dialogcontent') - -javascript [nonce=content_security_policy_nonce]: - - $('.modal-content').draggable({ - handle: '.modal-header' - }).resizable({ - autoHide: true - }); - - var solvedButton = $('#mark-as-solved-button'); - var addCommentExerciseButton = $('#addCommentExerciseButton'); - - var thankYouContainer = $('#thank-you-container'); - - solvedButton.on('click', function(){ - $.ajax({ - dataType: 'json', - method: 'GET', - url: '#{ mark_as_solved_request_for_comment_path(@request_for_comment) }' - }).done(function(response){ - if(response.solved){ - solvedButton.removeClass('btn-primary'); - solvedButton.addClass('btn-success'); - solvedButton.html("#{t('request_for_comments.solved')}"); - solvedButton.off('click'); - thankYouContainer.show(); - } - }); - }); - - $('#send-thank-you-note').on('click', function () { - var value = $('#thank-you-note').val(); - if (value) { - $.ajax({ - dataType: 'json', - method: 'POST', - url: '#{ set_thank_you_note_request_for_comment_path(@request_for_comment) }', - data: { - note: value - } - }).done(function() { - thankYouContainer.hide(); - }); - } - }); - - $('#cancel-thank-you-note').on('click', function () { - thankYouContainer.hide(); - }); - - $('.text > .collapse-button').on('click', function(e) { - $(this).toggleClass('fa-chevron-down'); - $(this).toggleClass('fa-chevron-up'); - $(this).parent().toggleClass('collapsed'); - }); - - // set file paths for ace - var ACE_FILES_PATH = "#{Rails.application.config.relative_url_root.chomp('/')}/assets/ace/"; - _.each(['modePath', 'themePath', 'workerPath'], function(attribute) { - ace.config.set(attribute, ACE_FILES_PATH); - }); - - var commentitor = $('.editor'); - - commentitor.each(function (index, editor) { - var currentEditor = ace.edit(editor); - currentEditor.setReadOnly(true); - // set editor mode (used for syntax highlighting - currentEditor.getSession().setMode($(editor).data('mode')); - currentEditor.getSession().setOption("useWorker", false); - - currentEditor.commentVisualsByLine = {}; - setAnnotations(currentEditor, $(editor).data('file-id')); - currentEditor.on("guttermousedown", handleSidebarClick); - currentEditor.on("guttermousemove", showPopover); - }); - - function preprocess(commentText) { - // sanitize comments to deal with XSS attacks: - commentText = $('div.sanitizer').text(commentText).html(); - // display original line breaks: - return commentText.replace(/\n/g, '
'); - } - - function replaceNewlineTags(commentText) { - // display original line breaks as \n: - return commentText.replace(/
/g, '\n'); - } - - function generateCommentHtmlContent(comments) { - var htmlContent = ''; - comments.forEach(function(comment, index) { - var commentText = preprocess(comment.text); - if (index !== 0) { - htmlContent += '
' - } - htmlContent += '\ -
\ -
\ -
' + preprocess(comment.username) + '
\ -
' + comment.date + '
\ -
\ - \ - #{{ t('request_for_comments.comment_edited') }} \ -
\ -
\ -
' + commentText + '
\ - \ -
\ - \ - \ -
\ -
'; - }); - return htmlContent; - } - - function buildPopover(comments, where) { - // only display the newest three comments in preview - var maxComments = 3; - var htmlContent = generateCommentHtmlContent(comments.reverse().slice(0, maxComments)); - if (comments.length > maxComments) { - // add a hint that there are more comments than shown here - htmlContent += '' - .replace('${numComments}', String(comments.length - maxComments)); - } - where.popover({ - content: htmlContent, - html: true, // necessary to style comments. XSS is not possible due to comment pre-processing (sanitizing) - trigger: 'manual', // can only be triggered by $(where).popover('show' | 'hide') - container: 'body' - }); - } - - function setAnnotations(editor, fileid) { - var session = editor.getSession(); - - var jqrequest = $.ajax({ - dataType: 'json', - method: 'GET', - url: Routes.comments_path(), - data: { - file_id: fileid - } - }); - - jqrequest.done(function(response){ - $.each(response, function(index, comment) { - comment.className = 'code-ocean_comment'; - }); - session.setAnnotations(response); - }); - } - - function getCommentsForRow(editor, row){ - return editor.getSession().getAnnotations().filter(function(element) { - return element.row === row; - }) - } - - function deleteComment(commentId, editor, file_id, callback) { - var jqxhr = $.ajax({ - type: 'DELETE', - url: Routes.comment_path(commentId) - }); - jqxhr.done(function () { - setAnnotations(editor, file_id); - callback(); - }); - jqxhr.fail(ajaxError); - } - - function updateComment(commentId, text, editor, file_id, callback) { - var jqxhr = $.ajax({ - type: 'PATCH', - url: Routes.comment_path(commentId), - data: { - comment: { - text: text - } - } - }); - jqxhr.done(function () { - setAnnotations(editor, file_id); - callback(); - }); - jqxhr.fail(ajaxError); - } - - function createComment(file_id, row, editor, commenttext){ - var jqxhr = $.ajax({ - data: { - comment: { - file_id: file_id, - row: row, - column: 0, - text: commenttext, - request_id: $('h4#exercise_caption').data('rfc-id') - } - }, - dataType: 'json', - method: 'POST', - url: Routes.comments_path() - }); - jqxhr.done(function(){ - setAnnotations(editor, file_id); - }); - jqxhr.fail(ajaxError); - } - - function subscribeToRFC(subscriptionType, checkbox){ - checkbox.attr("disabled", true); - var jqxhr = $.ajax({ - data: { - subscription: { - request_for_comment_id: $('h4#exercise_caption').data('rfc-id'), - subscription_type: subscriptionType - } - }, - dataType: 'json', - method: 'POST', - url: Routes.subscriptions_path({format: 'json'}) - }); - jqxhr.done(function(subscription) { - checkbox.data('subscription', subscription.id); - checkbox.attr("disabled", false); - }); - jqxhr.fail(function(response) { - checkbox.prop('checked', false); - checkbox.attr("disabled", false); - ajaxError(response); - }); - } - - function unsubscribeFromRFC(checkbox) { - checkbox.attr("disabled", true); - var subscriptionId = checkbox.data('subscription'); - var jqxhr = $.ajax({ - url: Routes.unsubscribe_subscription_path(subscriptionId, {format: 'json'}) - }); - jqxhr.done(function(response) { - checkbox.prop('checked', false); - checkbox.data('subscription', null); - checkbox.attr("disabled", false); - $.flash.success({text: response.message}); - }); - jqxhr.fail(function(response) { - checkbox.prop('checked', true); - checkbox.attr("disabled", false); - ajaxError(response); - }); - } - - var lastRow = null; - var lastTarget = null; - function showPopover(e) { - var target = e.domEvent.target; - var row = e.getDocumentPosition().row; - - if (target.className.indexOf('ace_gutter-cell') === -1 || lastRow === row) { - return; - } - if (lastTarget === target) { - // sometimes the row gets updated before the DOM event target, so we need to wait for it to change - return; - } - lastRow = row; - - var editor = e.editor; - var comments = getCommentsForRow(editor, row); - buildPopover(comments, $(target)); - lastTarget = target; - - $(target).popover('show'); - $(target).on('mouseleave', function () { - $(this).off('mouseleave'); - $(this).popover('dispose'); - }); - } - - $('.ace_gutter').on('mouseleave', function () { - lastRow = null; - lastTarget = null; - }); - - function handleSidebarClick(e) { - var target = e.domEvent.target; - if (target.className.indexOf('ace_gutter-cell') === -1) return; - - var editor = e.editor; - var fileid = $(editor.container).data('file-id'); - - var row = e.getDocumentPosition().row; - e.stop(); - $('.modal-title').text("#{ t('request_for_comments.modal_title') }".replace('${line}', row + 1)); - - var commentModal = $('#comment-modal'); - - var otherComments = commentModal.find('#otherComments'); - var htmlContent = generateCommentHtmlContent(getCommentsForRow(editor, row)); - if (htmlContent) { - otherComments.show(); - var container = otherComments.find('.container'); - container.html(htmlContent); - - var deleteButtons = container.find('.action-delete'); - deleteButtons.on('click', function (event) { - var button = $(event.target); - var parent = $(button).parent().parent(); - var commentId = parent.data('comment-id'); - - deleteComment(commentId, editor, fileid, function () { - parent.html('
#{ t('comments.deleted') }
'); - }); - }); - - var editButtons = container.find('.action-edit'); - editButtons.on('click', function (event) { - var button = $(event.target); - var parent = $(button).parent().parent(); - var commentId = parent.data('comment-id'); - var currentlyEditing = button.data('editing'); - - var deleteButton = parent.find('.action-delete'); - var commentContent = parent.find('.comment-content'); - var commentEditor = parent.find('textarea.comment-editor'); - var commentUpdated = parent.find('.comment-updated'); - - if (currentlyEditing) { - updateComment(commentId, commentEditor.val(), editor, fileid, function () { - button.text("#{ t('shared.edit') }"); - button.data('editing', false); - commentContent.html(preprocess(commentEditor.val())); - deleteButton.show(); - commentContent.show(); - commentEditor.hide(); - commentUpdated.removeClass('d-none'); - }); - } else { - button.text("#{ t('comments.save_update') }"); - button.data('editing', true); - deleteButton.hide(); - commentContent.hide(); - commentEditor.val(replaceNewlineTags(commentEditor.val())); - commentEditor.show(); - } - }); - } else { - otherComments.hide(); - } - - var subscribeCheckbox = commentModal.find('#subscribe'); - subscribeCheckbox.prop('checked', subscribeCheckbox.data('subscription')); - subscribeCheckbox.off('change'); - subscribeCheckbox.on('change', function() { - if (this.checked) { - subscribeToRFC('author', $(this)); - } else { - unsubscribeFromRFC($(this)); - } - }); - - var addCommentButton = commentModal.find('#addCommentButton'); - addCommentButton.off('click'); - addCommentButton.on('click', function(){ - var commentTextarea = commentModal.find('#myComment > textarea'); - var commenttext = commentTextarea.val(); - if (commenttext !== "") { - createComment(fileid, row, editor, commenttext); - commentTextarea.val('') ; - bootstrap.Modal.getInstance(commentModal).hide(); - } - }); - - new bootstrap.Modal(commentModal).show(); - } - - function ajaxError(response) { - const responseJSON = ((response || {}).responseJSON || {}); - const message = responseJSON.message || responseJSON.error || ''; - - $.flash.danger({ - text: message.length > 0 ? message : $('#flash').data('message-failure'), - showPermanent: response.status === 422, - }); - } diff --git a/config/locales/de.yml b/config/locales/de.yml index 6e816076..747017a0 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -801,8 +801,8 @@ de: send_thank_you_note: "Senden" cancel_thank_you_note: "Nichts senden" comment_edited: "bearbeitet" - modal_title: "Einen Kommentar in Zeile ${line} hinzufügen" - click_for_more_comments: "Klicken um ${numComments} weitere Kommentare zu sehen..." + modal_title: "Einen Kommentar in Zeile %{line} hinzufügen" + click_for_more_comments: "Klicken um %{numComments} weitere Kommentare zu sehen..." subscribe_to_author: "Bei neuen Kommentaren des Autors per E-Mail benachrichtigt werden" no_output: "Keine Ausgabe." runtime_output: "Programmausgabe" diff --git a/config/locales/en.yml b/config/locales/en.yml index 084b361a..d1f50f7b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -801,8 +801,8 @@ en: send_thank_you_note: "Send" cancel_thank_you_note: "Don't send" comment_edited: "edited" - modal_title: "Add a comment to line ${line}" - click_for_more_comments: "Click to view ${numComments} more comments..." + modal_title: "Add a comment to line %{line}" + click_for_more_comments: "Click to view %{numComments} more comments..." subscribe_to_author: "Receive E-Mail notifications for new comments of the original author" no_output: "No output." runtime_output: "Runtime Output"