Skip to content

Commit

Permalink
Make AlpineJS CSP friendly (#6453)
Browse files Browse the repository at this point in the history
* Make AlpineJS CSP friendly (#6442)

* show message after cancelling survey

* remove unsafe-eval for script-src

---------

Co-authored-by: Tasos Katsoulas <akatsoulas@gmail.com>
  • Loading branch information
escattone and akatsoulas authored Jan 15, 2025
1 parent d2f0741 commit b5edc21
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 72 deletions.
1 change: 0 additions & 1 deletion kitsune/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1167,7 +1167,6 @@ def filter_exceptions(event, hint):

CSP_SCRIPT_SRC: tuple[str, ...] = (
"'self'",
"'unsafe-eval'",
"https://*.mozilla.org",
"https://*.webservices.mozgcp.net",
"https://*.google-analytics.com",
Expand Down
4 changes: 3 additions & 1 deletion kitsune/sumo/static/sumo/js/alpine.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Alpine from 'alpinejs';
import Alpine from "@alpinejs/csp";
import trackEvent from "sumo/js/analytics";
// we need to import surveyForm here so it's available to Alpine components
import surveyForm from "sumo/js/survey_form";

window.Alpine = Alpine;
// Add trackEvent to the window object so it's available to Alpine components
Expand Down
116 changes: 116 additions & 0 deletions kitsune/sumo/static/sumo/js/survey_form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
document.addEventListener('alpine:init', () => {
Alpine.data('surveyForm', () => ({
selectedReason: '',
comment: '',
maxLength: 600,
hasError: false,
responseMessage: null,
formVisible: true,
isOtherSelected: false,
isOtherNotSelected: true,
remainingChars: 600,
isSubmitDisabled: true,

setupForm() {
// Setup radio buttons
const radioInputs = this.$root.querySelectorAll('input[type="radio"]');
const textarea = this.$root.querySelector('textarea[name="comment"]');
const form = this.$root.querySelector('form');

// Setup radio buttons
radioInputs.forEach(radio => {
radio.checked = radio.value === this.selectedReason;

radio.addEventListener('change', () => {
this.selectedReason = radio.value;
this.isOtherSelected = radio.value === 'other';
this.isOtherNotSelected = radio.value !== 'other';

if (textarea) {
textarea.disabled = radio.value !== 'other';
textarea.required = radio.value === 'other';
}

this.updateSubmitDisabled();
});
});

// Setup textarea
if (textarea) {
textarea.addEventListener('input', (event) => {
this.comment = event.target.value;
if (this.comment.length > this.maxLength) {
this.comment = this.comment.slice(0, this.maxLength);
event.target.value = this.comment;
}
this.remainingChars = this.maxLength - this.comment.length;
this.updateSubmitDisabled();
});
}

// Setup form submission
if (form) {
form.addEventListener('submit', (event) => {
event.preventDefault();
if (this.validateForm()) {
trackEvent('article_survey_submitted', {
survey_type: this.surveyType,
reason: this.selectedReason
});
// Allow HTMX to handle the submission
return true;
}
return false;
});
}
},

validateForm() {
this.hasError = this.isOtherSelected && !this.comment.trim();
return !this.hasError;
},

closeSurvey() {
if (this.surveyType) {
trackEvent('article_survey_closed', { survey_type: this.surveyType });
}
const survey = document.querySelector('.document-vote');
if (this.responseMessage) {
setTimeout(() => {
survey.remove();
}, 5000);
} else {
survey.remove();
}
},

cancelSurvey() {
this.formVisible = false;
this.responseMessage = "Thanks for voting! Your additional feedback wasn't submitted.";
this.closeSurvey();
},

init() {
this.surveyType = document.querySelector('.survey-container').dataset.surveyType.trim();
if (this.surveyType) {
trackEvent('article_survey_opened', { survey_type: this.surveyType });
}
this.responseMessage = document.querySelector('[x-ref="messageData"]').value;
this.formVisible = !this.responseMessage;
if (this.responseMessage) {
this.closeSurvey();
}

// Setup form after initialization
this.$nextTick(() => {
this.setupForm();
});
},

updateSubmitDisabled() {
const hasValidReason = !!this.selectedReason;
const hasValidComment = !this.isOtherSelected || (this.isOtherSelected && this.comment.trim());
this.isSubmitDisabled = !hasValidReason || !hasValidComment;
}
}));
});
71 changes: 12 additions & 59 deletions kitsune/wiki/jinja2/wiki/includes/survey_form.html
Original file line number Diff line number Diff line change
@@ -1,59 +1,13 @@
<div class="survey-container" x-data="{
selectedReason: '',
comment: '',
maxLength: 600,
hasError: false,
responseMessage: null,
validateForm() {
this.hasError = this.selectedReason === 'other' && !this.comment.trim();
return !this.hasError;
},
handleSubmit() {
if (this.validateForm()) {
trackEvent('article_survey_submitted', {
survey_type: '{{ survey_type }}',
reason: this.selectedReason
});
return true; // Allow HTMX to proceed with submission
}
return false;
},
closeSurvey(message) {
const surveyType = '{{ survey_type }}'.trim();
if (surveyType) {
trackEvent('article_survey_closed', { survey_type: surveyType });
}
const survey = document.querySelector('.document-vote');
if (message) {
this.responseMessage = message;
setTimeout(() => {
survey.remove();
}, 5000);
} else {
survey.remove();
}
}
}" x-init="
const surveyType = '{{ survey_type }}'.trim();
if (surveyType) {
trackEvent('article_survey_opened', { survey_type: surveyType });
}
responseMessage = $refs.messageData.value;
if (responseMessage) {
setTimeout(() => {
closeSurvey();
}, 5000);
}
">
<div class="survey-container" x-data="surveyForm" data-survey-type="{{ survey_type }}">
<input type="hidden" x-ref="messageData" value="{{ response_message if response_message else '' }}">
<template x-if="responseMessage">
<div class="survey-message">
<p x-text="responseMessage"></p>
</div>
</template>

<template x-if="!responseMessage">
<form hx-post="{{ action_url }}" hx-target=".survey-container" @submit.prevent="handleSubmit()" hx-trigger="submit">
<template x-if="formVisible">
<form hx-post="{{ action_url }}" hx-target=".survey-container" hx-trigger="submit">
{% csrf_token %}
<input type="hidden" name="vote_id" value="{{ vote_id }}" />
<input type="hidden" name="revision_id" value="{{ revision_id }}" />
Expand All @@ -64,32 +18,31 @@ <h3 class="sumo-card-heading text-center">
<ul id="{{ survey_type }}-contents">
{% for option in survey_options %}
<li class="field is-condensed radio">
<input type="radio" name="{{ survey_type }}-reason" value="{{ option.value }}"
id="{{ survey_type }}_{{ loop.index }}" x-model="selectedReason" />
<input type="radio"
name="{{ survey_type }}-reason"
value="{{ option.value }}"
id="{{ survey_type }}_{{ loop.index }}" />
<label for="{{ survey_type }}_{{ loop.index }}">{{ option.text }}</label>
</li>
{% endfor %}
</ul>
<p class="comments-label align-start">
{{ _('Comments') }}
<span x-show="selectedReason === 'other'" class="required-text">{{ _('Required') }}</span>
<span x-show="isOtherSelected" class="required-text">{{ _('Required') }}</span>
</p>
<textarea name="comment"
placeholder="{{ _('To protect your privacy, please do not include any personal information.') }}"
x-model="comment" :required="selectedReason === 'other'" :disabled="selectedReason !== 'other'"
@input="if (comment.length > maxLength) comment = comment.slice(0, maxLength)"></textarea>
placeholder="{{ _('To protect your privacy, please do not include any personal information.') }}"></textarea>
<p class="character-counter">
<span x-text="maxLength - comment.length"></span> {{ _('characters remaining.') }}
<span x-text="remainingChars"></span> {{ _('characters remaining.') }}
</p>
<p class="error-text" x-show="hasError" style="display: none; color: red;">{{ _('Please provide more details.') }}
</p>
<div class="sumo-button-wrap align-full">
<button class="sumo-button button-sm secondary-button" type="button"
@click="closeSurvey('Thanks for voting! Your additional feedback wasn\'t submitted.')">
<button class="sumo-button button-sm secondary-button" type="button" @click="cancelSurvey">
{{ _('Cancel') }}
</button>
<button class="sumo-button button-sm primary-button" type="submit"
:disabled="!selectedReason || (selectedReason === 'other' && !comment.trim())">
x-bind:disabled="isSubmitDisabled">
{{ _('Submit') }}
</button>
</div>
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
},
"license": "MPL-2.0",
"dependencies": {
"@alpinejs/csp": "^3.14.8",
"@babel/runtime": "^7.20.13",
"@urql/svelte": "^3.0.3",
"alpinejs": "^3.14.7",
"codemirror": "^5.65.11",
"d3": "^3.5.17",
"graphql": "^16.8.1",
Expand Down

0 comments on commit b5edc21

Please sign in to comment.