Skip to content

Commit 3aadeda

Browse files
Merge branch 'main' into custom-label-mapping
# Conflicts: # src/collective/volto/formsupport/restapi/services/submit_form/post.py # src/collective/volto/formsupport/tests/test_send_action_form.py
2 parents a094d68 + bd82db5 commit 3aadeda

File tree

15 files changed

+577
-258
lines changed

15 files changed

+577
-258
lines changed

CHANGES.rst

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,50 @@
11
Changelog
22
=========
33

4-
3.1.4 (unreleased)
4+
3.2.2 (unreleased)
55
------------------
66

7+
- Nothing changed yet.
8+
9+
10+
3.2.1 (2025-01-09)
11+
------------------
12+
13+
- Adapt email subject templating functionality to different value types.
14+
[folix-01]
15+
16+
17+
3.2.0 (2024-11-15)
18+
------------------
19+
20+
- Added an adapter (`IDataAdapter`) to allow information to be added as a return value
21+
to the form-data expander. This allows addons that integrate information to be added
22+
rather than overwriting the expander each time.
23+
[mamico]
24+
25+
- Add FormSubmittedEvent to handle the new compiled forms.
26+
[folix-01]
27+
28+
- Add PostAdapter to predispose the customization of data handling by other add-ons.
29+
[folix-01]
30+
31+
32+
3.1.5 (2024-10-24)
33+
------------------
34+
35+
- Fix otp verification logic: do not break if otp is not in POST call
36+
[cekk]
37+
38+
39+
3.1.4 (2024-09-27)
40+
------------------
41+
42+
- Add missing collective.volto.otp include for pip environment setup
43+
[folix-01]
44+
45+
- Switchable email bcc fields OTP verification.
46+
[folix-01]
47+
748
- Added ISO formatted strings being allowed as date inputs
849
[JeffersonBledsoe]
950

news/+translation.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update Brazilian Portuguese translation. [@ericof]

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
setup(
1717
name="collective.volto.formsupport",
18-
version="3.1.4.dev0",
18+
version="3.2.2.dev0",
1919
description="Add support for customizable forms in Volto",
2020
long_description=long_description,
2121
# Get more from https://pypi.org/classifiers/

src/collective/volto/formsupport/adapters/__init__.py

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<configure
2+
xmlns="http://namespaces.zope.org/zope"
3+
xmlns:plone="http://namespaces.plone.org/plone"
4+
i18n_domain="collective.volto.formsupport"
5+
>
6+
7+
<adapter factory=".post.PostAdapter" />
8+
9+
</configure>
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
from collective.volto.formsupport import _
2+
from collective.volto.formsupport.interfaces import ICaptchaSupport
3+
from collective.volto.formsupport.interfaces import IPostAdapter
4+
from collective.volto.formsupport.utils import get_blocks
5+
from collective.volto.otp.utils import validate_email_token
6+
from copy import deepcopy
7+
from plone import api
8+
from plone.restapi.deserializer import json_body
9+
from plone.schema.email import _isemail
10+
from zExceptions import BadRequest
11+
from zope.component import adapter
12+
from zope.component import getMultiAdapter
13+
from zope.i18n import translate
14+
from zope.interface import implementer
15+
from zope.interface import Interface
16+
17+
import math
18+
import os
19+
20+
21+
@implementer(IPostAdapter)
22+
@adapter(Interface, Interface)
23+
class PostAdapter:
24+
block_id = None
25+
block = {}
26+
27+
def __init__(self, context, request):
28+
self.context = context
29+
self.request = request
30+
self.form_data = self.extract_data_from_request()
31+
self.block_id = self.form_data.get("block_id", "")
32+
if self.block_id:
33+
self.block = self.get_block_data(block_id=self.block_id)
34+
35+
def __call__(self):
36+
"""
37+
Avoid XSS injections and other attacks.
38+
39+
- cleanup HTML with plone transform
40+
- remove from data, fields not defined in form schema
41+
"""
42+
43+
self.validate_form()
44+
45+
return self.form_data
46+
47+
def extract_data_from_request(self):
48+
form_data = json_body(self.request)
49+
50+
fixed_fields = []
51+
transforms = api.portal.get_tool(name="portal_transforms")
52+
53+
block = self.get_block_data(block_id=form_data.get("block_id", ""))
54+
block_fields = [x.get("field_id", "") for x in block.get("subblocks", [])]
55+
custom_block_fields = [
56+
block.get(field_id) for field_id in block_fields if block.get(field_id)
57+
]
58+
59+
for form_field in form_data.get("data", []):
60+
field_id = form_field.get("custom_field_id", form_field.get("field_id", ""))
61+
if field_id not in block_fields and field_id not in custom_block_fields:
62+
# unknown field, skip it
63+
continue
64+
new_field = deepcopy(form_field)
65+
value = new_field.get("value", "")
66+
if isinstance(value, str):
67+
stream = transforms.convertTo("text/plain", value, mimetype="text/html")
68+
new_field["value"] = stream.getData().strip()
69+
fixed_fields.append(new_field)
70+
71+
form_data["data"] = fixed_fields
72+
73+
return form_data
74+
75+
def get_block_data(self, block_id):
76+
blocks = get_blocks(self.context)
77+
if not blocks:
78+
return {}
79+
for id, block in blocks.items():
80+
if id != block_id:
81+
continue
82+
block_type = block.get("@type", "")
83+
if block_type != "form":
84+
continue
85+
return block
86+
return {}
87+
88+
def validate_form(self):
89+
"""
90+
check all required fields and parameters
91+
"""
92+
if not self.block_id:
93+
raise BadRequest(
94+
translate(
95+
_("missing_blockid_label", default="Missing block_id"),
96+
context=self.request,
97+
)
98+
)
99+
if not self.block:
100+
raise BadRequest(
101+
translate(
102+
_(
103+
"block_form_not_found_label",
104+
default='Block with @type "form" and id "$block" not found in this context: $context',
105+
mapping={
106+
"block": self.block_id,
107+
"context": self.context.absolute_url(),
108+
},
109+
),
110+
context=self.request,
111+
),
112+
)
113+
114+
if not self.block.get("store", False) and not self.block.get("send", []):
115+
raise BadRequest(
116+
translate(
117+
_(
118+
"missing_action",
119+
default='You need to set at least one form action between "send" and "store".', # noqa
120+
),
121+
context=self.request,
122+
)
123+
)
124+
125+
if not self.form_data.get("data", []):
126+
raise BadRequest(
127+
translate(
128+
_(
129+
"empty_form_data",
130+
default="Empty form data.",
131+
),
132+
context=self.request,
133+
)
134+
)
135+
136+
self.validate_attachments()
137+
if self.block.get("captcha", False):
138+
getMultiAdapter(
139+
(self.context, self.request),
140+
ICaptchaSupport,
141+
name=self.block["captcha"],
142+
).verify(self.form_data.get("captcha"))
143+
144+
self.validate_bcc()
145+
146+
def validate_bcc(self):
147+
"""
148+
If otp validation is enabled, check if is valid
149+
"""
150+
bcc_fields = []
151+
email_otp_verification = self.block.get("email_otp_verification", False)
152+
block_id = self.form_data.get("block_id", "")
153+
for field in self.block.get("subblocks", []):
154+
if field.get("use_as_bcc", False):
155+
field_id = field.get("field_id", "")
156+
if field_id not in bcc_fields:
157+
bcc_fields.append(field_id)
158+
if not bcc_fields:
159+
return
160+
if not email_otp_verification:
161+
return
162+
for data in self.form_data.get("data", []):
163+
value = data.get("value", "")
164+
if not value:
165+
continue
166+
if data.get("field_id", "") not in bcc_fields:
167+
continue
168+
otp = data.get("otp", "")
169+
if not otp:
170+
raise BadRequest(
171+
api.portal.translate(
172+
_(
173+
"otp_validation_missing_value",
174+
default="Missing OTP value. Unable to submit the form.",
175+
)
176+
)
177+
)
178+
if not validate_email_token(block_id, value, otp):
179+
raise BadRequest(
180+
api.portal.translate(
181+
_(
182+
"otp_validation_wrong_value",
183+
default="${email}'s OTP is wrong",
184+
mapping={"email": data["value"]},
185+
)
186+
)
187+
)
188+
189+
def validate_attachments(self):
190+
attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "")
191+
if not attachments_limit:
192+
return
193+
attachments = self.form_data.get("attachments", {})
194+
attachments_len = 0
195+
for attachment in attachments.values():
196+
data = attachment.get("data", "")
197+
attachments_len += (len(data) * 3) / 4 - data.count("=", -2)
198+
if attachments_len > float(attachments_limit) * pow(1024, 2):
199+
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
200+
i = int(math.floor(math.log(attachments_len, 1024)))
201+
p = math.pow(1024, i)
202+
s = round(attachments_len / p, 2)
203+
uploaded_str = f"{s} {size_name[i]}"
204+
raise BadRequest(
205+
translate(
206+
_(
207+
"attachments_too_big",
208+
default="Attachments too big. You uploaded ${uploaded_str},"
209+
" but limit is ${max} MB. Try to compress files.",
210+
mapping={
211+
"max": attachments_limit,
212+
"uploaded_str": uploaded_str,
213+
},
214+
),
215+
context=self.request,
216+
)
217+
)
218+
219+
def filter_parameters(self):
220+
"""
221+
do not send attachments fields.
222+
"""
223+
result = []
224+
225+
for field in self.block.get("subblocks", []):
226+
if field.get("field_type", "") == "attachment":
227+
continue
228+
229+
for item in self.form_data.get("data", []):
230+
if item.get("field_id", "") == field.get("field_id", ""):
231+
result.append(item)
232+
233+
return result
234+
235+
def format_fields(self):
236+
fields = self.filter_parameters()
237+
formatted_fields = []
238+
field_ids = [field.get("field_id") for field in self.block.get("subblocks", [])]
239+
240+
for field in fields:
241+
field_id = field.get("field_id", "")
242+
243+
if field_id:
244+
field_index = field_ids.index(field_id)
245+
246+
if self.block["subblocks"][field_index].get("field_type") == "date":
247+
field["value"] = api.portal.get_localized_time(field["value"])
248+
249+
formatted_fields.append(field)
250+
251+
return formatted_fields

src/collective/volto/formsupport/configure.zcml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
-->
1515
<!--<includeDependencies package="." />-->
1616

17+
<include package="collective.volto.otp" />
1718
<include package="souper.plone" />
1819

1920
<include package=".browser" />
2021
<include package=".datamanager" />
2122
<include package=".restapi" />
2223
<include package=".captcha" />
24+
<include package=".adapters" />
2325

2426
<include file="permissions.zcml" />
2527
<include file="upgrades.zcml" />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from collective.volto.formsupport.interfaces import IFormSubmittedEvent
2+
from zope.interface import implementer
3+
from zope.interface.interfaces import ObjectEvent
4+
5+
6+
@implementer(IFormSubmittedEvent)
7+
class FormSubmittedEvent(ObjectEvent):
8+
def __init__(self, obj, form, form_data):
9+
super().__init__(obj)
10+
self.form = form
11+
self.form_data = form_data

src/collective/volto/formsupport/interfaces.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from zope.interface import Attribute
12
from zope.interface import Interface
3+
from zope.interface.interfaces import IObjectEvent
24
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
35

46

@@ -44,3 +46,26 @@ def verify(data):
4446
"""Verify the captcha
4547
@return: True if verified, Raise exception otherwise
4648
"""
49+
50+
51+
class IFormSubmittedEvent(IObjectEvent):
52+
"""An event that's fired upon a workflow transition."""
53+
54+
obj = Attribute("The context object")
55+
56+
form = Attribute("Form")
57+
form_data = Attribute("Form Data")
58+
59+
60+
class IPostAdapter(Interface):
61+
def data():
62+
pass
63+
64+
65+
# BBB
66+
IFormData = IPostAdapter
67+
68+
69+
class IDataAdapter(Interface):
70+
def __call__(result, block_id=None):
71+
pass

0 commit comments

Comments
 (0)