diff --git a/bsyncviewer/templates/validator.html b/bsyncviewer/templates/validator.html
index d6877b3..67c8d21 100644
--- a/bsyncviewer/templates/validator.html
+++ b/bsyncviewer/templates/validator.html
@@ -9,7 +9,6 @@
Validate your XML against the BuildingSync schema and its use cases. View the Use Case Validator API docs on GitHub.
-
@@ -48,6 +47,8 @@
Explore Examples
+
Privacy: Uploaded files are used only to run the validation and are deleted immediately afterward; they are not retained.
+
{% endblock content %}
diff --git a/bsyncviewer/tests/test_api.py b/bsyncviewer/tests/test_api.py
index 5d40e13..85f5ef7 100644
--- a/bsyncviewer/tests/test_api.py
+++ b/bsyncviewer/tests/test_api.py
@@ -1,4 +1,6 @@
import os
+import tempfile
+from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
@@ -80,6 +82,46 @@ def test_validator_zip_api(self):
print("VALID SCHEMA? {}".format(valid_schema))
self.assertTrue(valid_schema)
+ def _post_and_list_leftovers(self, filename, workflow_error=False):
+ """Post a file to the validator API using a private temp dir; return leftover entries."""
+ filepath = os.path.join(os.path.dirname(__file__), "data", filename)
+ with open(filepath, "rb") as f:
+ upload = SimpleUploadedFile(filename, f.read())
+ data = {"schema_version": TEST_SCHEMA_VERSION, "file": upload}
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with mock.patch.object(tempfile, "tempdir", tmpdir):
+ if workflow_error:
+ with mock.patch(
+ "bsyncviewer.views.ValidationWorkflow.validate_all",
+ side_effect=RuntimeError("boom"),
+ ):
+ response = self.client.post(
+ reverse("validate_api"), data, format="multipart"
+ )
+ else:
+ response = self.client.post(
+ reverse("validate_api"), data, format="multipart"
+ )
+ return response, os.listdir(tmpdir)
+
+ def test_uploaded_xml_is_not_retained(self):
+ response, leftovers = self._post_and_list_leftovers("test_valid_schema.xml")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(leftovers, [])
+
+ def test_uploaded_zip_is_not_retained(self):
+ response, leftovers = self._post_and_list_leftovers("example_files.zip")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(leftovers, [])
+
+ def test_uploaded_xml_is_not_retained_on_error(self):
+ response, leftovers = self._post_and_list_leftovers(
+ "test_valid_schema.xml", workflow_error=True
+ )
+ self.assertFalse(response.data["success"])
+ self.assertEqual(leftovers, [])
+
def tearDown(self):
# clean-up files on disk
print("IN TEAR DOWN NOW")
diff --git a/bsyncviewer/views.py b/bsyncviewer/views.py
index eeb01c3..3b997d4 100644
--- a/bsyncviewer/views.py
+++ b/bsyncviewer/views.py
@@ -1,3 +1,4 @@
+import contextlib
import json
import os
import pathlib
@@ -55,6 +56,32 @@
)
+@contextlib.contextmanager
+def saved_upload(uploaded_file):
+ """
+ Write an uploaded file to a temporary path for validation. The file is
+ always deleted on exit, even if validation raises, so uploads are never retained.
+ """
+ tmp_file = tempfile.NamedTemporaryFile(delete=False)
+ try:
+ for chunk in uploaded_file.chunks():
+ tmp_file.write(chunk)
+ tmp_file.close()
+ if settings.DEBUG:
+ print(f"[validation upload] Temporary path: {tmp_file.name}", flush=True)
+ yield tmp_file.name
+ finally:
+ tmp_file.close()
+ with contextlib.suppress(FileNotFoundError):
+ os.unlink(tmp_file.name)
+ if settings.DEBUG:
+ print(
+ f"[validation upload] Cleanup: {tmp_file.name}; "
+ f"exists={os.path.exists(tmp_file.name)}",
+ flush=True,
+ )
+
+
class ValidatorApi(views.APIView):
"""
API endpoint for validating schemas
@@ -84,89 +111,79 @@ def post(self, request, *args, **kwargs):
f = request.data["file"]
if f:
- tmp_file = tempfile.NamedTemporaryFile(delete=False)
- filepath = tmp_file.name
- # print('tmp file at: {}'.format(filepath))
- for chunk in f.chunks():
- tmp_file.write(chunk)
- tmp_file.close()
-
- # check if zipfile or single XML
- if zipfile.is_zipfile(filepath):
- with zipfile.ZipFile(filepath, "r") as zip_file:
- try:
- tmp_dir = tempfile.TemporaryDirectory()
- dirname = tmp_dir.name
-
- # make clean filenames for response (weird encoding assumptions by zipfile module)
- filenames = []
- for member in zip_file.infolist():
- if member.filename.endswith(
- ".xml"
- ) and not member.filename.startswith("__MACOSX"):
- filenames.append(
- {
- "new": member.filename.encode("cp437").decode(
- "utf-8"
- ),
- "old": member.filename,
- }
- )
-
- zip_file.extractall(dirname)
-
- results = []
- all_valid = True
-
- for the_file in filenames:
- fp = dirname + "/" + the_file["old"]
- wf = ValidationWorkflow(the_file["old"], fp, version)
- val_res = wf.validate_all()
-
- if val_res["schema"]["valid"] is False:
- all_valid = False
-
- results.append(
- {"file": the_file["new"], "results": val_res}
+ with saved_upload(f) as filepath:
+ # check if zipfile or single XML
+ if zipfile.is_zipfile(filepath):
+ return self._validate_zip(filepath, version)
+ return self._validate_xml(f, filepath, version)
+
+ else:
+ return Response(
+ {"success": False, "error": "No schema_version or file parameters sent"}
+ )
+
+ def _validate_zip(self, filepath, version):
+ with zipfile.ZipFile(filepath, "r") as zip_file:
+ try:
+ with tempfile.TemporaryDirectory() as dirname:
+ # make clean filenames for response (weird encoding assumptions by zipfile module)
+ filenames = []
+ for member in zip_file.infolist():
+ if member.filename.endswith(
+ ".xml"
+ ) and not member.filename.startswith("__MACOSX"):
+ filenames.append(
+ {
+ "new": member.filename.encode("cp437").decode(
+ "utf-8"
+ ),
+ "old": member.filename,
+ }
)
- return Response(
- {
- "schema_version": version,
- "all_files_valid": all_valid,
- "validation_results": results,
- "success": True,
- }
- )
- except BaseException as err:
- print(err)
- return Response(
- {"success": False, "error": "error processing Zip file"}
- )
-
- else:
- try:
- workflow = ValidationWorkflow(f, filepath, version)
- validation_results = workflow.validate_all()
- # cleanup file after validation
- os.unlink(tmp_file.name)
+ zip_file.extractall(dirname)
+
+ results = []
+ all_valid = True
+
+ for the_file in filenames:
+ fp = dirname + "/" + the_file["old"]
+ wf = ValidationWorkflow(the_file["old"], fp, version)
+ val_res = wf.validate_all()
+
+ if val_res["schema"]["valid"] is False:
+ all_valid = False
+
+ results.append({"file": the_file["new"], "results": val_res})
return Response(
{
"schema_version": version,
- "validation_results": validation_results,
+ "all_files_valid": all_valid,
+ "validation_results": results,
"success": True,
}
)
- except BaseException:
- return Response(
- {"success": False, "error": "error processing XML file"}
- )
+ except BaseException as err:
+ print(err)
+ return Response(
+ {"success": False, "error": "error processing Zip file"}
+ )
+
+ def _validate_xml(self, f, filepath, version):
+ try:
+ workflow = ValidationWorkflow(f, filepath, version)
+ validation_results = workflow.validate_all()
- else:
return Response(
- {"success": False, "error": "No schema_version or file parameters sent"}
+ {
+ "schema_version": version,
+ "validation_results": validation_results,
+ "success": True,
+ }
)
+ except BaseException:
+ return Response({"success": False, "error": "error processing XML file"})
def index(request):
@@ -540,17 +557,12 @@ def validator(request):
validated = True
if validated:
+ # holds the temporary upload (if any) so it is always deleted after validation
+ upload_stack = contextlib.ExitStack()
if form_type == "file":
f = request.FILES["file"]
filename = f.name
-
- # save tmp file
- tmp_file = tempfile.NamedTemporaryFile(delete=False)
- filepath = tmp_file.name
- # print('tmp file at: {}'.format(filepath))
- for chunk in f.chunks():
- tmp_file.write(chunk)
- tmp_file.close()
+ filepath = upload_stack.enter_context(saved_upload(f))
else:
if "download" not in request.POST:
@@ -590,14 +602,9 @@ def validator(request):
raise Http404
else:
- workflow = ValidationWorkflow(f, filepath, version)
- validation_results = workflow.validate_all()
-
- # print(validation_results)
-
- # cleanup file after validation
- if form_type == "file":
- os.unlink(tmp_file.name)
+ with upload_stack:
+ workflow = ValidationWorkflow(f, filepath, version)
+ validation_results = workflow.validate_all()
return render(
request,