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"{tag}>")
+
+ 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"{data}>")
+
+ 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 &
+ assert 'data-value="test&value"' in result
+ assert "&" 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("