6
6
7
7
from flask import Flask , render_template , request , jsonify
8
8
from flask_recaptcha import ReCaptcha
9
+ from flask_limiter import Limiter
10
+ from flask_limiter .util import get_remote_address
9
11
10
12
from sendgrid import SendGridAPIClient
11
13
from sendgrid .helpers .mail import (Mail , Attachment , FileContent , FileName , FileType , Disposition )
14
16
15
17
load_dotenv ()
16
18
17
- if not set (['RECAPTCHASITEKEY' , 'RECAPTCHASECRETKEY' , 'SENDGRIDAPIKEY' , 'SENDGRIDFROMEMAIL' ]).issubset (os .environ ):
18
- print ("Failed to start. Please set the environment variables RECAPTCHASITEKEY, RECAPTCHASECRETKEY, SENDGRIDAPIKEY, and SENDGRIDFROMEMAIL" )
19
- exit (1 )
20
-
21
- RECAPTCHASITEKEY = os .environ ['RECAPTCHASITEKEY' ]
22
- RECAPTCHASECRETKEY = os .environ ['RECAPTCHASECRETKEY' ]
23
- SENDGRIDAPIKEY = os .environ ['SENDGRIDAPIKEY' ]
24
- FROMEMAIL = os .environ ['SENDGRIDFROMEMAIL' ]
25
-
26
- # this needs to be reflected in the `templates/index.html` file
27
- NUMBER_OF_ATTACHMENTS = int (os .environ .get ('NUMBEROFATTACHMENTS' , '10' ))
28
- DEBUG = os .environ .get ('DEBUG' , 'False' ).lower () == 'true'
29
-
30
- app = Flask (__name__ )
31
- app .config ['RECAPTCHA_SITE_KEY' ] = RECAPTCHASITEKEY
32
- app .config ['RECAPTCHA_SECRET_KEY' ] = RECAPTCHASECRETKEY
33
- app .config ['MAX_CONTENT_LENGTH' ] = 15 * 1024 * 1024 # 15 Mb limit
34
- recaptcha = ReCaptcha (app )
35
-
36
- log_file = os .environ .get ('LOG_FILE' , '' )
37
- if log_file :
38
- logging .basicConfig (filename = log_file , level = logging .INFO )
39
- else :
40
- logging .basicConfig (level = logging .INFO )
19
+ class Config :
20
+ MAX_CONTENT_LENGTH = 15 * 1024 * 1024 # 15 MB
21
+ EMAIL_DOMAIN = "@ethereum.org"
22
+ DEFAULT_RECIPIENT_EMAIL = "[email protected] "
23
+ NUMBER_OF_ATTACHMENTS = int (os .getenv ('NUMBEROFATTACHMENTS' , 10 ))
24
+ DEBUG_MODE = os .getenv ('DEBUG' , 'False' ).lower () == 'true'
25
+ SECRET_KEY = os .getenv ('SECRET_KEY' , 'you-should-set-a-secret-key' )
26
+
27
+ def validate_env_vars (required_vars ):
28
+ """
29
+ Validates that all required environment variables are set.
30
+ """
31
+ missing_vars = [var for var in required_vars if var not in os .environ ]
32
+ if missing_vars :
33
+ raise EnvironmentError (f"Missing required environment variables: { ', ' .join (missing_vars )} " )
34
+
35
+ def sanitize_filename (filename ):
36
+ """
37
+ Sanitizes the filename to prevent directory traversal and other issues.
38
+ """
39
+ return filename .replace (".." , "" ).replace ("/" , "" ).replace ("\\ " , "" )
41
40
42
41
def parse_form (form ):
42
+ """
43
+ Parses the form data to extract the message, recipient, and attachments.
44
+ """
43
45
text = form ['message' ]
44
46
recipient = form ['recipient' ]
45
47
46
48
all_attachments = []
47
- for i in range (NUMBER_OF_ATTACHMENTS ):
48
- attachment = form [ 'attachment-%s' % i ]
49
- filename = form [ 'filename-%s' % i ] .encode ('ascii' , 'ignore' ).decode () # remove non-ascii characters
49
+ for i in range (Config . NUMBER_OF_ATTACHMENTS ):
50
+ attachment = form . get ( f 'attachment-{ i } ' )
51
+ filename = form . get ( f 'filename-{ i } ' , '' ) .encode ('ascii' , 'ignore' ).decode () # remove non-ascii characters
50
52
if not attachment :
51
53
continue
52
- all_attachments .append ((filename , attachment ))
54
+ sanitized_filename = sanitize_filename (filename )
55
+ all_attachments .append ((sanitized_filename , attachment ))
53
56
return text , recipient , all_attachments
54
57
55
58
def valid_recipient (recipient ):
56
- if recipient in ['legal' , 'devcon' , 'esp' , 'security' , 'oleh' ]:
57
- return True
58
- return False
59
+ """
60
+ Checks if the recipient is valid.
61
+ """
62
+ valid_recipients = ['legal' , 'devcon' , 'esp' , 'security' , 'oleh' ]
63
+ return recipient in valid_recipients
59
64
60
65
def get_identifier (recipient , now = None , randint = None ):
66
+ """
67
+ Generates a unique identifier based on the recipient, current timestamp, and a random number.
68
+ """
61
69
if now is None :
62
70
now = datetime .now ()
63
71
if randint is None :
64
72
randint = Random ().randint (1000 , 9999 )
65
- return '%s:%s:%s' % ( recipient , now .strftime (' %Y:%m:%d:%H:%M:%S' ), randint )
73
+ return f' { recipient } : { now .strftime (" %Y:%m:%d:%H:%M:%S" ) } : { randint } '
66
74
67
- def create_email (toEmail , identifier , text , all_attachments ):
75
+ def create_email (to_email , identifier , text , all_attachments ):
76
+ """
77
+ Creates an email message with attachments.
78
+ """
68
79
plain_text = text .replace ('<br />' , '\n ' )
69
80
message = Mail (
70
- from_email = FROMEMAIL ,
71
- to_emails = toEmail ,
72
- subject = 'Secure Form Submission %s' % identifier ,
73
- plain_text_content = plain_text )
74
-
75
- for item in all_attachments :
76
- filename = item ['filename' ]
77
- attachment = item ['attachment' ]
81
+ from_email = FROMEMAIL ,
82
+ to_emails = to_email ,
83
+ subject = f'Secure Form Submission { identifier } ' ,
84
+ plain_text_content = plain_text
85
+ )
78
86
87
+ for filename , attachment in all_attachments :
79
88
encoded_file = base64 .b64encode (attachment .encode ("utf-8" )).decode ()
80
- attachedFile = Attachment (
89
+ attached_file = Attachment (
81
90
FileContent (encoded_file ),
82
91
FileName (filename + '.pgp' ),
83
92
FileType ('application/pgp-encrypted' ),
84
93
Disposition ('attachment' )
85
94
)
86
- message .add_attachment (attachedFile )
95
+ message .add_attachment (attached_file )
87
96
return message
88
97
98
+ def validate_recaptcha (recaptcha_response ):
99
+ """
100
+ Validates the ReCaptcha response.
101
+ """
102
+ if not recaptcha .verify (response = recaptcha_response ):
103
+ raise ValueError ('Error: ReCaptcha verification failed!' )
104
+
105
+ def send_email (message ):
106
+ """
107
+ Sends the email using SendGrid.
108
+ """
109
+ sg = SendGridAPIClient (SENDGRIDAPIKEY )
110
+ response = sg .send (message )
111
+ if response .status_code not in [200 , 201 , 202 ]:
112
+ raise ValueError (f"Error: Failed to send email. Status code: { response .status_code } " )
113
+
114
+ # Validate required environment variables
115
+ required_env_vars = ['RECAPTCHASITEKEY' , 'RECAPTCHASECRETKEY' , 'SENDGRIDAPIKEY' , 'SENDGRIDFROMEMAIL' ]
116
+ validate_env_vars (required_env_vars )
117
+
118
+ RECAPTCHASITEKEY = os .environ ['RECAPTCHASITEKEY' ]
119
+ RECAPTCHASECRETKEY = os .environ ['RECAPTCHASECRETKEY' ]
120
+ SENDGRIDAPIKEY = os .environ ['SENDGRIDAPIKEY' ]
121
+ FROMEMAIL = os .environ ['SENDGRIDFROMEMAIL' ]
122
+
123
+ app = Flask (__name__ )
124
+ app .config .from_object (Config )
125
+ recaptcha = ReCaptcha (app )
126
+
127
+ # Initialize rate limiting
128
+ limiter = Limiter (get_remote_address , app = app , default_limits = ["200 per day" , "50 per hour" ])
129
+
130
+ # Configure logging
131
+ log_file = os .environ .get ('LOG_FILE' , '' )
132
+
133
+ if log_file :
134
+ logging .basicConfig (filename = log_file , level = logging .INFO )
135
+ else :
136
+ logging .basicConfig (level = logging .INFO )
137
+
89
138
@app .route ('/' , methods = ['GET' ])
90
139
def index ():
91
- return render_template ('index.html' , notice = '' , hascaptcha = not DEBUG , attachments_number = NUMBER_OF_ATTACHMENTS , recaptcha_sitekey = RECAPTCHASITEKEY )
140
+ return render_template ('index.html' , notice = '' , hascaptcha = not Config . DEBUG_MODE , attachments_number = Config . NUMBER_OF_ATTACHMENTS , recaptcha_sitekey = RECAPTCHASITEKEY )
92
141
93
142
@app .route ('/submit-encrypted-data' , methods = ['POST' ])
143
+ @limiter .limit ("5 per minute" )
94
144
def submit ():
95
145
try :
96
146
# Parse JSON data from request
97
147
data = request .get_json ()
98
148
99
- # Won't even look on Captcha for debug mode
100
- if not DEBUG :
101
- if not recaptcha .verify (response = data ['g-recaptcha-response' ]):
102
- raise ValueError ('Error: ReCaptcha verification failed! You would need to re-submit the request.' )
103
-
149
+ # Validate ReCaptcha unless in debug mode
150
+ if not Config .DEBUG_MODE :
151
+ validate_recaptcha (data ['g-recaptcha-response' ])
152
+
104
153
# Extract fields from JSON data
105
154
message = data ['message' ]
106
155
recipient = data ['recipient' ]
@@ -111,44 +160,37 @@ def submit():
111
160
112
161
if not valid_recipient (recipient ):
113
162
raise ValueError ('Error: Invalid recipient!' )
114
-
163
+
115
164
# Get submission statistics
116
165
date = datetime .now ().strftime ('%Y-%m-%d %H:%M:%S' )
117
166
message_length = len (message )
118
167
file_count = len (files )
119
-
120
- toEmail = "[email protected] " if recipient == 'legal' else recipient + "@ethereum.org"
168
+
169
+ to_email = Config . DEFAULT_RECIPIENT_EMAIL if recipient == 'legal' else recipient + Config . EMAIL_DOMAIN
121
170
identifier = get_identifier (recipient )
122
-
123
171
124
172
log_data = f"{ date } - message to: { recipient } , identifier: { identifier } , length: { message_length } , file count: { file_count } "
125
173
logging .info (log_data )
126
174
127
- message = create_email (toEmail , identifier , message , files )
175
+ message = create_email (to_email , identifier , message , files )
128
176
129
- if DEBUG :
130
- print ("Attempt to send email to %s" % toEmail )
177
+ if Config . DEBUG_MODE :
178
+ print (f "Attempt to send email to { to_email } " )
131
179
print (message .get ())
132
180
else :
133
- sg = SendGridAPIClient (SENDGRIDAPIKEY )
134
- response = sg .send (message )
135
- if not response .status_code in [200 , 201 , 202 ]:
136
- logging .error ("Failed to send email: %s" % response .body )
137
- logging .error ("Headers: %s" % response .headers )
138
- raise ValueError ('Error: Failed to send email. Please try again later. Code: %s' % response .status_code )
139
-
140
- notice = 'Thank you! The relevant team was notified of your submission. You could use a following identifier to refer to it in correspondence: <b>' + identifier + '</b>'
141
-
181
+ send_email (message )
182
+
183
+ notice = f'Thank you! The relevant team was notified of your submission. You could use the following identifier to refer to it in correspondence: <b>{ identifier } </b>'
184
+
142
185
# Return success response
143
186
return jsonify ({'status' : 'success' , 'message' : notice })
144
-
187
+
145
188
except Exception as e :
146
189
# Log error message and return failure response
147
- error_message = str ( e )
148
- print ( error_message )
190
+ error_message = "An unexpected error occurred. Please try again later."
191
+ logging . error ( f"Internal error: { str ( e ) } " )
149
192
return jsonify ({'status' : 'failure' , 'message' : error_message })
150
193
151
-
152
194
@app .errorhandler (413 )
153
195
def error413 (e ):
154
196
return render_template ('413.html' ), 413
0 commit comments