##// END OF EJS Templates
Extend PoW lifetime to 5 mins
neko259 -
r1447:237dee1d default
parent child Browse files
Show More
@@ -1,443 +1,443 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4 import logging
5 5
6 6 import pytz
7 7
8 8 from django import forms
9 9 from django.core.files.uploadedfile import SimpleUploadedFile
10 10 from django.core.exceptions import ObjectDoesNotExist
11 11 from django.forms.util import ErrorList
12 12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
13 13 from django.utils import timezone
14 14
15 15 from boards.mdx_neboard import formatters
16 16 from boards.models.attachment.downloaders import Downloader
17 17 from boards.models.post import TITLE_MAX_LENGTH
18 18 from boards.models import Tag, Post
19 19 from boards.utils import validate_file_size, get_file_mimetype, \
20 20 FILE_EXTENSION_DELIMITER
21 21 from neboard import settings
22 22 import boards.settings as board_settings
23 23 import neboard
24 24
25 25 POW_HASH_LENGTH = 16
26 POW_LIFE_MINUTES = 1
26 POW_LIFE_MINUTES = 5
27 27
28 28 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
29 29 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
30 30
31 31 VETERAN_POSTING_DELAY = 5
32 32
33 33 ATTRIBUTE_PLACEHOLDER = 'placeholder'
34 34 ATTRIBUTE_ROWS = 'rows'
35 35
36 36 LAST_POST_TIME = 'last_post_time'
37 37 LAST_LOGIN_TIME = 'last_login_time'
38 38 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
39 39 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
40 40
41 41 LABEL_TITLE = _('Title')
42 42 LABEL_TEXT = _('Text')
43 43 LABEL_TAG = _('Tag')
44 44 LABEL_SEARCH = _('Search')
45 45
46 46 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
47 47 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
48 48
49 49 TAG_MAX_LENGTH = 20
50 50
51 51 TEXTAREA_ROWS = 4
52 52
53 53 TRIPCODE_DELIM = '#'
54 54
55 55 # TODO Maybe this may be converted into the database table?
56 56 MIMETYPE_EXTENSIONS = {
57 57 'image/jpeg': 'jpeg',
58 58 'image/png': 'png',
59 59 'image/gif': 'gif',
60 60 'video/webm': 'webm',
61 61 'application/pdf': 'pdf',
62 62 'x-diff': 'diff',
63 63 'image/svg+xml': 'svg',
64 64 'application/x-shockwave-flash': 'swf',
65 65 }
66 66
67 67
68 68 def get_timezones():
69 69 timezones = []
70 70 for tz in pytz.common_timezones:
71 71 timezones.append((tz, tz),)
72 72 return timezones
73 73
74 74
75 75 class FormatPanel(forms.Textarea):
76 76 """
77 77 Panel for text formatting. Consists of buttons to add different tags to the
78 78 form text area.
79 79 """
80 80
81 81 def render(self, name, value, attrs=None):
82 82 output = '<div id="mark-panel">'
83 83 for formatter in formatters:
84 84 output += '<span class="mark_btn"' + \
85 85 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
86 86 '\', \'' + formatter.format_right + '\')">' + \
87 87 formatter.preview_left + formatter.name + \
88 88 formatter.preview_right + '</span>'
89 89
90 90 output += '</div>'
91 91 output += super(FormatPanel, self).render(name, value, attrs=attrs)
92 92
93 93 return output
94 94
95 95
96 96 class PlainErrorList(ErrorList):
97 97 def __unicode__(self):
98 98 return self.as_text()
99 99
100 100 def as_text(self):
101 101 return ''.join(['(!) %s ' % e for e in self])
102 102
103 103
104 104 class NeboardForm(forms.Form):
105 105 """
106 106 Form with neboard-specific formatting.
107 107 """
108 108
109 109 def as_div(self):
110 110 """
111 111 Returns this form rendered as HTML <as_div>s.
112 112 """
113 113
114 114 return self._html_output(
115 115 # TODO Do not show hidden rows in the list here
116 116 normal_row='<div class="form-row">'
117 117 '<div class="form-label">'
118 118 '%(label)s'
119 119 '</div>'
120 120 '<div class="form-input">'
121 121 '%(field)s'
122 122 '</div>'
123 123 '</div>'
124 124 '<div class="form-row">'
125 125 '%(help_text)s'
126 126 '</div>',
127 127 error_row='<div class="form-row">'
128 128 '<div class="form-label"></div>'
129 129 '<div class="form-errors">%s</div>'
130 130 '</div>',
131 131 row_ender='</div>',
132 132 help_text_html='%s',
133 133 errors_on_separate_row=True)
134 134
135 135 def as_json_errors(self):
136 136 errors = []
137 137
138 138 for name, field in list(self.fields.items()):
139 139 if self[name].errors:
140 140 errors.append({
141 141 'field': name,
142 142 'errors': self[name].errors.as_text(),
143 143 })
144 144
145 145 return errors
146 146
147 147
148 148 class PostForm(NeboardForm):
149 149
150 150 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
151 151 label=LABEL_TITLE,
152 152 widget=forms.TextInput(
153 153 attrs={ATTRIBUTE_PLACEHOLDER:
154 154 'test#tripcode'}))
155 155 text = forms.CharField(
156 156 widget=FormatPanel(attrs={
157 157 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
158 158 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
159 159 }),
160 160 required=False, label=LABEL_TEXT)
161 161 file = forms.FileField(required=False, label=_('File'),
162 162 widget=forms.ClearableFileInput(
163 163 attrs={'accept': 'file/*'}))
164 164 file_url = forms.CharField(required=False, label=_('File URL'),
165 165 widget=forms.TextInput(
166 166 attrs={ATTRIBUTE_PLACEHOLDER:
167 167 'http://example.com/image.png'}))
168 168
169 169 # This field is for spam prevention only
170 170 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
171 171 widget=forms.TextInput(attrs={
172 172 'class': 'form-email'}))
173 173 threads = forms.CharField(required=False, label=_('Additional threads'),
174 174 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
175 175 '123 456 789'}))
176 176
177 177 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
178 178 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
179 179 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
180 180
181 181 session = None
182 182 need_to_ban = False
183 183
184 184 def _update_file_extension(self, file):
185 185 if file:
186 186 mimetype = get_file_mimetype(file)
187 187 extension = MIMETYPE_EXTENSIONS.get(mimetype)
188 188 if extension:
189 189 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
190 190 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
191 191
192 192 file.name = new_filename
193 193 else:
194 194 logger = logging.getLogger('boards.forms.extension')
195 195
196 196 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
197 197
198 198 def clean_title(self):
199 199 title = self.cleaned_data['title']
200 200 if title:
201 201 if len(title) > TITLE_MAX_LENGTH:
202 202 raise forms.ValidationError(_('Title must have less than %s '
203 203 'characters') %
204 204 str(TITLE_MAX_LENGTH))
205 205 return title
206 206
207 207 def clean_text(self):
208 208 text = self.cleaned_data['text'].strip()
209 209 if text:
210 210 max_length = board_settings.get_int('Forms', 'MaxTextLength')
211 211 if len(text) > max_length:
212 212 raise forms.ValidationError(_('Text must have less than %s '
213 213 'characters') % str(max_length))
214 214 return text
215 215
216 216 def clean_file(self):
217 217 file = self.cleaned_data['file']
218 218
219 219 if file:
220 220 validate_file_size(file.size)
221 221 self._update_file_extension(file)
222 222
223 223 return file
224 224
225 225 def clean_file_url(self):
226 226 url = self.cleaned_data['file_url']
227 227
228 228 file = None
229 229 if url:
230 230 file = self._get_file_from_url(url)
231 231
232 232 if not file:
233 233 raise forms.ValidationError(_('Invalid URL'))
234 234 else:
235 235 validate_file_size(file.size)
236 236 self._update_file_extension(file)
237 237
238 238 return file
239 239
240 240 def clean_threads(self):
241 241 threads_str = self.cleaned_data['threads']
242 242
243 243 if len(threads_str) > 0:
244 244 threads_id_list = threads_str.split(' ')
245 245
246 246 threads = list()
247 247
248 248 for thread_id in threads_id_list:
249 249 try:
250 250 thread = Post.objects.get(id=int(thread_id))
251 251 if not thread.is_opening() or thread.get_thread().is_archived():
252 252 raise ObjectDoesNotExist()
253 253 threads.append(thread)
254 254 except (ObjectDoesNotExist, ValueError):
255 255 raise forms.ValidationError(_('Invalid additional thread list'))
256 256
257 257 return threads
258 258
259 259 def clean(self):
260 260 cleaned_data = super(PostForm, self).clean()
261 261
262 262 if cleaned_data['email']:
263 263 self.need_to_ban = True
264 264 raise forms.ValidationError('A human cannot enter a hidden field')
265 265
266 266 if not self.errors:
267 267 self._clean_text_file()
268 268
269 269 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
270 270 if not self.errors and limit_speed:
271 271 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
272 272 if pow_difficulty > 0 and cleaned_data['timestamp'] and cleaned_data['iteration'] and cleaned_data['guess']:
273 273 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
274 274 else:
275 275 self._validate_posting_speed()
276 276
277 277 return cleaned_data
278 278
279 279 def get_file(self):
280 280 """
281 281 Gets file from form or URL.
282 282 """
283 283
284 284 file = self.cleaned_data['file']
285 285 return file or self.cleaned_data['file_url']
286 286
287 287 def get_tripcode(self):
288 288 title = self.cleaned_data['title']
289 289 if title is not None and TRIPCODE_DELIM in title:
290 290 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
291 291 tripcode = hashlib.md5(code.encode()).hexdigest()
292 292 else:
293 293 tripcode = ''
294 294 return tripcode
295 295
296 296 def get_title(self):
297 297 title = self.cleaned_data['title']
298 298 if title is not None and TRIPCODE_DELIM in title:
299 299 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
300 300 else:
301 301 return title
302 302
303 303 def _clean_text_file(self):
304 304 text = self.cleaned_data.get('text')
305 305 file = self.get_file()
306 306
307 307 if (not text) and (not file):
308 308 error_message = _('Either text or file must be entered.')
309 309 self._errors['text'] = self.error_class([error_message])
310 310
311 311 def _validate_posting_speed(self):
312 312 can_post = True
313 313
314 314 posting_delay = settings.POSTING_DELAY
315 315
316 316 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
317 317 now = time.time()
318 318
319 319 current_delay = 0
320 320
321 321 if LAST_POST_TIME not in self.session:
322 322 self.session[LAST_POST_TIME] = now
323 323
324 324 need_delay = True
325 325 else:
326 326 last_post_time = self.session.get(LAST_POST_TIME)
327 327 current_delay = int(now - last_post_time)
328 328
329 329 need_delay = current_delay < posting_delay
330 330
331 331 if need_delay:
332 332 delay = posting_delay - current_delay
333 333 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
334 334 delay) % {'delay': delay}
335 335 self._errors['text'] = self.error_class([error_message])
336 336
337 337 can_post = False
338 338
339 339 if can_post:
340 340 self.session[LAST_POST_TIME] = now
341 341
342 342 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
343 343 """
344 344 Gets an file file from URL.
345 345 """
346 346
347 347 img_temp = None
348 348
349 349 try:
350 350 for downloader in Downloader.__subclasses__():
351 351 if downloader.handles(url):
352 352 return downloader.download(url)
353 353 # If nobody of the specific downloaders handles this, use generic
354 354 # one
355 355 return Downloader.download(url)
356 356 except forms.ValidationError as e:
357 357 raise e
358 358 except Exception as e:
359 359 raise forms.ValidationError(e)
360 360
361 361 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
362 362 post_time = timezone.datetime.fromtimestamp(
363 363 int(timestamp[:-3]), tz=timezone.get_current_timezone())
364 364 timedelta = (timezone.now() - post_time).seconds / 60
365 365 if timedelta > POW_LIFE_MINUTES:
366 366 self._errors['text'] = self.error_class([_('Stale PoW.')])
367 367
368 368 payload = timestamp + message.replace('\r\n', '\n')
369 369 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
370 370 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
371 371 if len(target) < POW_HASH_LENGTH:
372 372 target = '0' * (POW_HASH_LENGTH - len(target)) + target
373 373
374 374 computed_guess = hashlib.sha256((payload + iteration).encode())\
375 375 .hexdigest()[0:POW_HASH_LENGTH]
376 376 if guess != computed_guess or guess > target:
377 377 self._errors['text'] = self.error_class(
378 378 [_('Invalid PoW.')])
379 379
380 380
381 381 class ThreadForm(PostForm):
382 382
383 383 tags = forms.CharField(
384 384 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
385 385 max_length=100, label=_('Tags'), required=True)
386 386 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
387 387
388 388 def clean_tags(self):
389 389 tags = self.cleaned_data['tags'].strip()
390 390
391 391 if not tags or not REGEX_TAGS.match(tags):
392 392 raise forms.ValidationError(
393 393 _('Inappropriate characters in tags.'))
394 394
395 395 required_tag_exists = False
396 396 tag_set = set()
397 397 for tag_string in tags.split():
398 398 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
399 399 tag_set.add(tag)
400 400
401 401 # If this is a new tag, don't check for its parents because nobody
402 402 # added them yet
403 403 if not created:
404 404 tag_set |= set(tag.get_all_parents())
405 405
406 406 for tag in tag_set:
407 407 if tag.required:
408 408 required_tag_exists = True
409 409 break
410 410
411 411 if not required_tag_exists:
412 412 raise forms.ValidationError(
413 413 _('Need at least one section.'))
414 414
415 415 return tag_set
416 416
417 417 def clean(self):
418 418 cleaned_data = super(ThreadForm, self).clean()
419 419
420 420 return cleaned_data
421 421
422 422 def is_monochrome(self):
423 423 return self.cleaned_data['monochrome']
424 424
425 425
426 426 class SettingsForm(NeboardForm):
427 427
428 428 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
429 429 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
430 430 username = forms.CharField(label=_('User name'), required=False)
431 431 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
432 432
433 433 def clean_username(self):
434 434 username = self.cleaned_data['username']
435 435
436 436 if username and not REGEX_USERNAMES.match(username):
437 437 raise forms.ValidationError(_('Inappropriate characters.'))
438 438
439 439 return username
440 440
441 441
442 442 class SearchForm(NeboardForm):
443 443 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
General Comments 0
You need to be logged in to leave comments. Login now