-
-
Notifications
You must be signed in to change notification settings - Fork 18
Fix Safari compatibility with autonomous custom element that replicates HTMLInputElement interface #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix Safari compatibility with autonomous custom element that replicates HTMLInputElement interface #342
Changes from all commits
602816e
8878e3e
d215e06
2e9a36c
5136300
562bdde
0275372
df4e9f0
32b07ed
623a926
622f286
8233407
8b17c41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| from django.templatetags.static import static | ||
| from django.utils.functional import cached_property | ||
| from django.utils.html import format_html, html_safe | ||
| from django.utils.safestring import mark_safe | ||
| from storages.utils import safe_join | ||
|
|
||
| from s3file.middleware import S3FileMiddleware | ||
|
|
@@ -75,7 +76,6 @@ def client(self): | |
|
|
||
| def build_attrs(self, *args, **kwargs): | ||
| attrs = super().build_attrs(*args, **kwargs) | ||
| attrs["is"] = "s3-file" | ||
|
|
||
| accept = attrs.get("accept") | ||
| response = self.client.generate_presigned_post( | ||
|
|
@@ -97,6 +97,14 @@ def build_attrs(self, *args, **kwargs): | |
|
|
||
| return defaults | ||
|
|
||
| def render(self, name, value, attrs=None, renderer=None): | ||
| """Render the widget as a custom element for Safari compatibility.""" | ||
| return mark_safe( # noqa: S308 | ||
| str(super().render(name, value, attrs=attrs, renderer=renderer)).replace( | ||
| f'<input type="{self.input_type}"', "<s3-file" | ||
| ) | ||
| ) | ||
|
Comment on lines
+100
to
+106
|
||
|
|
||
| def get_conditions(self, accept): | ||
| conditions = [ | ||
| {"bucket": self.bucket_name}, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| /** | ||
| * Parse XML response from AWS S3 and return the file key. | ||
| * | ||
| * @param {string} responseText - XML response form AWS S3. | ||
| * @param {string} responseText - XML response from AWS S3. | ||
| * @return {string} - Key from response. | ||
| */ | ||
| export function getKeyFromResponse(responseText) { | ||
|
|
@@ -11,33 +11,201 @@ export function getKeyFromResponse(responseText) { | |
|
|
||
| /** | ||
| * Custom element to upload files to AWS S3. | ||
| * Safari-compatible autonomous custom element that acts as a file input. | ||
| * | ||
| * @extends HTMLInputElement | ||
| * @extends HTMLElement | ||
| */ | ||
| export class S3FileInput extends globalThis.HTMLInputElement { | ||
| export class S3FileInput extends globalThis.HTMLElement { | ||
| static passThroughAttributes = ["accept", "required", "multiple", "class", "style"] | ||
| constructor() { | ||
| super() | ||
| this.type = "file" | ||
| this.keys = [] | ||
| this.upload = null | ||
| this._files = [] | ||
| this._validationMessage = "" | ||
| this._internals = null | ||
|
|
||
| // Try to attach ElementInternals for form participation | ||
| try { | ||
| this._internals = this.attachInternals?.() | ||
| } catch (e) { | ||
| // ElementInternals not supported | ||
| } | ||
| } | ||
|
|
||
| connectedCallback() { | ||
| this.form.addEventListener("formdata", this.fromDataHandler.bind(this)) | ||
| this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) | ||
| this.form.addEventListener("upload", this.uploadHandler.bind(this)) | ||
| this.addEventListener("change", this.changeHandler.bind(this)) | ||
| // Create a hidden file input for the file picker functionality | ||
| this._fileInput = document.createElement("input") | ||
| this._fileInput.type = "file" | ||
|
|
||
| // Sync attributes to hidden input | ||
| this._syncAttributesToHiddenInput() | ||
|
|
||
| // Listen for file selection on hidden input | ||
| this._fileInput.addEventListener("change", () => { | ||
| this._files = this._fileInput.files | ||
| this.dispatchEvent(new Event("change", { bubbles: true })) | ||
| this.changeHandler() | ||
| }) | ||
|
|
||
| // Append elements | ||
| this.appendChild(this._fileInput) | ||
|
|
||
| // Setup form event listeners | ||
| this.form?.addEventListener("formdata", this.fromDataHandler.bind(this)) | ||
| this.form?.addEventListener("submit", this.submitHandler.bind(this), { | ||
| once: true, | ||
| }) | ||
| this.form?.addEventListener("upload", this.uploadHandler.bind(this)) | ||
| } | ||
|
|
||
| /** | ||
| * Sync attributes from custom element to hidden input. | ||
| */ | ||
| _syncAttributesToHiddenInput() { | ||
| if (!this._fileInput) return | ||
|
|
||
| S3FileInput.passThroughAttributes.forEach((attr) => { | ||
| if (this.hasAttribute(attr)) { | ||
| this._fileInput.setAttribute(attr, this.getAttribute(attr)) | ||
| } else { | ||
| this._fileInput.removeAttribute(attr) | ||
| } | ||
| }) | ||
|
|
||
| this._fileInput.disabled = this.hasAttribute("disabled") | ||
| } | ||
|
|
||
| /** | ||
| * Implement HTMLInputElement-like properties. | ||
| */ | ||
| get files() { | ||
| return this._files | ||
| } | ||
|
Comment on lines
+82
to
+84
|
||
|
|
||
| get type() { | ||
| return "file" | ||
| } | ||
|
|
||
| get name() { | ||
| return this.getAttribute("name") || "" | ||
| } | ||
|
|
||
| set name(value) { | ||
| this.setAttribute("name", value) | ||
| } | ||
|
|
||
| get value() { | ||
| if (this._files && this._files.length > 0) { | ||
| return this._files[0].name | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| set value(val) { | ||
| // Setting value on file inputs is restricted for security | ||
| if (val === "" || val === null) { | ||
| this._files = [] | ||
| if (this._fileInput) { | ||
| this._fileInput.value = "" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| get form() { | ||
| return this._internals?.form || this.closest("form") | ||
| } | ||
codingjoe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| get disabled() { | ||
| return this.hasAttribute("disabled") | ||
| } | ||
|
|
||
| set disabled(value) { | ||
| if (value) { | ||
| this.setAttribute("disabled", "") | ||
| } else { | ||
| this.removeAttribute("disabled") | ||
| } | ||
| } | ||
|
|
||
| get required() { | ||
| return this.hasAttribute("required") | ||
| } | ||
|
|
||
| set required(value) { | ||
| if (value) { | ||
| this.setAttribute("required", "") | ||
| } else { | ||
| this.removeAttribute("required") | ||
| } | ||
| } | ||
|
|
||
| get validity() { | ||
| if (this._internals) { | ||
| return this._internals.validity | ||
| } | ||
| // Create a basic ValidityState-like object | ||
| const isValid = !this.required || (this._files && this._files.length > 0) | ||
| return { | ||
| valid: isValid && !this._validationMessage, | ||
| valueMissing: this.required && (!this._files || this._files.length === 0), | ||
| customError: !!this._validationMessage, | ||
| badInput: false, | ||
| patternMismatch: false, | ||
| rangeOverflow: false, | ||
| rangeUnderflow: false, | ||
| stepMismatch: false, | ||
| tooLong: false, | ||
| tooShort: false, | ||
| typeMismatch: false, | ||
| } | ||
| } | ||
|
Comment on lines
+143
to
+162
|
||
|
|
||
| get validationMessage() { | ||
| return this._validationMessage | ||
| } | ||
|
|
||
| setCustomValidity(message) { | ||
| this._validationMessage = message || "" | ||
| if (this._internals && typeof this._internals.setValidity === "function") { | ||
| if (message) { | ||
| this._internals.setValidity({ customError: true }, message) | ||
| } else { | ||
| this._internals.setValidity({}) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| reportValidity() { | ||
| const validity = this.validity | ||
| if (validity && !validity.valid) { | ||
| this.dispatchEvent(new Event("invalid", { bubbles: false, cancelable: true })) | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| checkValidity() { | ||
| return this.validity.valid | ||
| } | ||
|
|
||
| click() { | ||
| if (this._fileInput) { | ||
| this._fileInput.click() | ||
| } | ||
| } | ||
|
|
||
| changeHandler() { | ||
| this.keys = [] | ||
| this.upload = null | ||
| try { | ||
| this.form.removeEventListener("submit", this.submitHandler.bind(this)) | ||
| this.form?.removeEventListener("submit", this.submitHandler.bind(this)) | ||
| } catch (error) { | ||
| console.debug(error) | ||
| } | ||
| this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) | ||
| this.form?.addEventListener("submit", this.submitHandler.bind(this), { | ||
| once: true, | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -48,15 +216,15 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| */ | ||
| async submitHandler(event) { | ||
| event.preventDefault() | ||
| this.form.dispatchEvent(new window.CustomEvent("upload")) | ||
| await Promise.all(this.form.pendingRquests) | ||
| this.form.requestSubmit(event.submitter) | ||
| this.form?.dispatchEvent(new window.CustomEvent("upload")) | ||
| await Promise.all(this.form?.pendingRquests) | ||
| this.form?.requestSubmit(event.submitter) | ||
| } | ||
|
|
||
| uploadHandler() { | ||
| if (this.files.length && !this.upload) { | ||
| this.upload = this.uploadFiles() | ||
| this.form.pendingRquests = this.form.pendingRquests || [] | ||
| this.form.pendingRquests = this.form?.pendingRquests || [] | ||
| this.form.pendingRquests.push(this.upload) | ||
| } | ||
| } | ||
|
|
@@ -99,7 +267,10 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| s3Form.append("file", file) | ||
| console.debug("uploading", this.dataset.url, file) | ||
| try { | ||
| const response = await fetch(this.dataset.url, { method: "POST", body: s3Form }) | ||
| const response = await fetch(this.dataset.url, { | ||
| method: "POST", | ||
| body: s3Form, | ||
| }) | ||
| if (response.status === 201) { | ||
| this.keys.push(getKeyFromResponse(await response.text())) | ||
| } else { | ||
|
|
@@ -108,11 +279,29 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| } | ||
| } catch (error) { | ||
| console.error(error) | ||
| this.setCustomValidity(error) | ||
| this.setCustomValidity(String(error)) | ||
| this.reportValidity() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Called when observed attributes change. | ||
| */ | ||
| static get observedAttributes() { | ||
| return this.passThroughAttributes.concat(["name", "id"]) | ||
| } | ||
|
|
||
| attributeChangedCallback(name, oldValue, newValue) { | ||
codingjoe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| this._syncAttributesToHiddenInput() | ||
codingjoe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** | ||
| * Declare this element as a form-associated custom element. | ||
| */ | ||
| static get formAssociated() { | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| globalThis.customElements.define("s3-file", S3FileInput, { extends: "input" }) | ||
| globalThis.customElements.define("s3-file", S3FileInput) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
mark_safewith string replacement on potentially user-controlled HTML is risky. The parent'srender()method produces HTML that could contain user-provided attribute values (like file names or custom attributes). The simple.replace()operation doesn't validate or sanitize the replacement, and if the parent render output contains unexpected patterns (e.g., multiple<input type="file"strings, or injected content that looks like the pattern), the replacement could produce malformed or exploitable HTML. While the immediate risk is low since Django's form rendering should be safe, consider using proper HTML parsing with a library likehtml.parseror BeautifulSoup for more robust transformation.