diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdb1bc9..acb9ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,11 +64,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - - name: Install Selenium - run: | - curl -LsSfO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb - sudo dpkg -i google-chrome-stable_current_amd64.deb || sudo apt-get -f install -y - if: matrix.os == 'ubuntu-latest' - uses: astral-sh/setup-uv@v7 - run: uv run pytest -m selenium - uses: codecov/codecov-action@v5 diff --git a/s3file/forms.py b/s3file/forms.py index a9f3453..21e7976 100644 --- a/s3file/forms.py +++ b/s3file/forms.py @@ -1,12 +1,15 @@ import base64 +import html import logging import pathlib import uuid +from html.parser import HTMLParser from django.conf import settings 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 @@ -15,6 +18,71 @@ logger = logging.getLogger("s3file") +class InputToS3FileRewriter(HTMLParser): + """HTML parser that rewrites to custom elements.""" + + def __init__(self): + super().__init__() + self.output = [] + + def handle_starttag(self, tag, attrs): + if tag == "input" and dict(attrs).get("type") == "file": + self.output.append("") + else: + self.output.append(self.get_starttag_text()) + + def handle_endtag(self, tag): + self.output.append(f"") + + def handle_data(self, data): + self.output.append(data) + + def handle_startendtag(self, tag, attrs): + if tag == "input" and dict(attrs).get("type") == "file": + self.output.append("") + else: + self.output.append(self.get_starttag_text()) + + def handle_comment(self, data): + # Preserve HTML comments in the output + self.output.append(f"") + + def handle_decl(self, decl): + # Preserve declarations such as in the output + self.output.append(f"") + + def handle_pi(self, data): + # Preserve processing instructions such as in the output + self.output.append(f"") + + def handle_entityref(self, name): + # Preserve HTML entities like &, <, > + self.output.append(f"&{name};") + + def handle_charref(self, name): + # Preserve character references like ', ' + self.output.append(f"&#{name};") + + def get_html(self): + return "".join(self.output) + + @html_safe class Asset: """A generic asset that can be included in a template.""" @@ -75,7 +143,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 +164,13 @@ 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.""" + html_output = str(super().render(name, value, attrs=attrs, renderer=renderer)) + parser = InputToS3FileRewriter() + parser.feed(html_output) + return mark_safe(parser.get_html()) # noqa: S308 + def get_conditions(self, accept): conditions = [ {"bucket": self.bucket_name}, diff --git a/s3file/static/s3file/js/s3file.js b/s3file/static/s3file/js/s3file.js index c36ad59..0d237ae 100644 --- a/s3file/static/s3file/js/s3file.js +++ b/s3file/static/s3file/js/s3file.js @@ -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,236 @@ 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)) + // Prevent creating duplicate inputs if already connected + if (this._fileInput) return + + // 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() + + // Create bound handler references for cleanup + this._changeHandler = () => { + this._files = this._fileInput.files + this.dispatchEvent(new Event("change", { bubbles: true })) + this.changeHandler() + } + this._boundFormDataHandler = this.fromDataHandler.bind(this) + this._boundSubmitHandler = this.submitHandler.bind(this) + this._boundUploadHandler = this.uploadHandler.bind(this) + + // Listen for file selection on hidden input + this._fileInput.addEventListener("change", this._changeHandler) + + // Append elements + this.appendChild(this._fileInput) + + // Setup form event listeners + this.form?.addEventListener("formdata", this._boundFormDataHandler) + this.form?.addEventListener("submit", this._boundSubmitHandler, { + once: true, + }) + this.form?.addEventListener("upload", this._boundUploadHandler) + } + + disconnectedCallback() { + // Clean up event listeners + if (this._fileInput) { + this._fileInput.removeEventListener("change", this._changeHandler) + } + + if (this.form) { + this.form.removeEventListener("formdata", this._boundFormDataHandler) + this.form.removeEventListener("submit", this._boundSubmitHandler) + this.form.removeEventListener("upload", this._boundUploadHandler) + } + + // Remove the file input from DOM + if (this._fileInput && this._fileInput.parentNode) { + this._fileInput.parentNode.removeChild(this._fileInput) + } + + // Clear reference to allow reconnection + this._fileInput = null + } + + /** + * 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 + } + + 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") + } + + 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, + } + } + + 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)) - } catch (error) { - console.debug(error) + // Remove previous submit handler and add a new one + if (this._boundSubmitHandler) { + try { + this.form?.removeEventListener("submit", this._boundSubmitHandler) + } catch (error) { + console.debug(error) + } } - this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) + // Create a new bound handler for this change + this._boundSubmitHandler = this.submitHandler.bind(this) + this.form?.addEventListener("submit", this._boundSubmitHandler, { + once: true, + }) } /** @@ -48,16 +251,16 @@ 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?.pendingRequests) + 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.push(this.upload) + this.form.pendingRequests = this.form?.pendingRequests || [] + this.form.pendingRequests.push(this.upload) } } @@ -99,7 +302,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 +314,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) { + this._syncAttributesToHiddenInput() + } + + /** + * 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) diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js index c5582d5..8fe8b79 100644 --- a/tests/__tests__/s3file.test.js +++ b/tests/__tests__/s3file.test.js @@ -26,7 +26,6 @@ describe("getKeyFromResponse", () => { describe("S3FileInput", () => { test("constructor", () => { const input = new s3file.S3FileInput() - assert.strictEqual(input.type, "file") assert.deepStrictEqual(input.keys, []) assert.strictEqual(input.upload, null) }) @@ -35,11 +34,48 @@ describe("S3FileInput", () => { const form = document.createElement("form") document.body.appendChild(form) const input = new s3file.S3FileInput() - input.addEventListener = mock.fn(input.addEventListener) form.addEventListener = mock.fn(form.addEventListener) form.appendChild(input) assert(form.addEventListener.mock.calls.length === 3) - assert(input.addEventListener.mock.calls.length === 1) + assert(input._fileInput !== null) + assert(input._fileInput.type === "file") + }) + + test("disconnectedCallback", () => { + const form = document.createElement("form") + document.body.appendChild(form) + const input = new s3file.S3FileInput() + form.addEventListener = mock.fn(form.addEventListener) + form.appendChild(input) + const fileInput = input._fileInput + assert(fileInput !== null) + // Mock removeEventListener to verify cleanup + form.removeEventListener = mock.fn(form.removeEventListener) + fileInput.removeEventListener = mock.fn(fileInput.removeEventListener) + // Manually call disconnectedCallback since jsdom doesn't trigger it + input.disconnectedCallback() + assert(form.removeEventListener.mock.calls.length === 3) + assert(fileInput.removeEventListener.mock.calls.length === 1) + assert(fileInput.parentNode === null) + assert(input._fileInput === null) + }) + + test("connectedCallback prevents duplicate inputs on reconnection", () => { + const form = document.createElement("form") + document.body.appendChild(form) + const input = new s3file.S3FileInput() + // First connection + form.appendChild(input) + assert(input._fileInput !== null) + assert(input.querySelectorAll('input[type="file"]').length === 1) + // Disconnect and clear state + input.disconnectedCallback() + // Reconnect - should create a new input since old one was cleaned up + input.connectedCallback() + assert(input._fileInput !== null) + // Check only one input element exists after reconnection + const inputElements = input.querySelectorAll('input[type="file"]') + assert(inputElements.length === 1) }) test("changeHandler", () => { @@ -56,7 +92,7 @@ describe("S3FileInput", () => { test("submitHandler", async () => { const form = document.createElement("form") document.body.appendChild(form) - form.pendingRquests = [] + form.pendingRequests = [] form.requestSubmit = mock.fn(form.requestSubmit) form.dispatchEvent = mock.fn(form.dispatchEvent) const submitButton = document.createElement("button") @@ -83,7 +119,7 @@ describe("S3FileInput", () => { input.uploadHandler() console.log(input.upload) assert(input.upload) - assert(form.pendingRquests) + assert(form.pendingRequests) }) test("fromDataHandler", () => { diff --git a/tests/test_forms.py b/tests/test_forms.py index e05d586..b330ffb 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -72,6 +72,100 @@ def test_str(self, settings): assert str(js) == '' +class TestInputToS3FileRewriter: + def test_transforms_file_input(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_non_file_input(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_handles_attribute_ordering(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result.startswith("' + ) + result = parser.get_html() + assert result.startswith("') + result = parser.get_html() + assert 'data-value="test&value"' in result + + def test_preserves_existing_html_entities(self): + # Test that already-escaped entities in input are preserved (not double-escaped) + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + # Should preserve the & entity, not convert to &amp; + assert 'data-value="test&value"' in result + assert "&amp;" not in result + + def test_preserves_character_references(self): + # Test that character references are preserved (may be in decimal or hex format) + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + # The character reference should be preserved (either ' or ' both represent ') + assert ( + 'data-value="test's"' in result or 'data-value="test's"' in result + ) + # Verify the actual apostrophe character is NOT directly in the output (should be a reference) + assert 'data-value="test\'s"' not in result or "&#" in result + + def test_handles_self_closing_tag(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_non_file_self_closing_tag(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + assert parser.get_html() == '' + + def test_preserves_surrounding_elements(self): + parser = forms.InputToS3FileRewriter() + parser.feed('

') + result = parser.get_html() + assert result == '

' + + def test_preserves_html_comments(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + def test_preserves_declarations(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + def test_preserves_processing_instructions(self): + parser = forms.InputToS3FileRewriter() + parser.feed('') + result = parser.get_html() + assert result == '' + + @contextmanager def wait_for_page_load(driver, timeout=30): old_page = driver.find_element(By.TAG_NAME, "html") @@ -127,7 +221,6 @@ def test_clear(self, filemodel): def test_build_attr(self, freeze_upload_folder): assert set(ClearableFileInput().build_attrs({}).keys()) == { - "is", "data-url", "data-fields-x-amz-algorithm", "data-fields-x-amz-date", @@ -141,7 +234,6 @@ def test_build_attr(self, freeze_upload_folder): ClearableFileInput().build_attrs({})["data-s3f-signature"] == "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI" ) - assert ClearableFileInput().build_attrs({})["is"] == "s3-file" def test_get_conditions(self, freeze_upload_folder): conditions = ClearableFileInput().get_conditions(None) @@ -182,6 +274,27 @@ def test_accept(self): "application/pdf,image/*" ) + def test_render_wraps_in_s3_file_element(self, freeze_upload_folder): + widget = ClearableFileInput() + html = widget.render(name="file", value=None) + # Check that the output is the s3-file custom element + assert html.startswith("