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