##// END OF EJS Templates
Minor import cleanup. Added forgotten fetch command file to mercurial
neko259 -
r1979:b71d904c default
parent child Browse files
Show More
@@ -0,0 +1,16 b''
1 from django.core.management import BaseCommand
2 from django.db import transaction
3
4 from boards.models.source import ThreadSource
5
6 __author__ = 'neko259'
7
8
9 class Command(BaseCommand):
10 help = 'Fetches data from sources and posts into appropriate threads'
11
12 @transaction.atomic
13 def handle(self, *args, **options):
14 for source in ThreadSource.objects.all():
15 source.fetch_latest_posts()
16
@@ -1,590 +1,586 b''
1 import hashlib
2 1 import logging
3 import re
4 2 import time
5 3
4 import hashlib
6 5 import pytz
7
6 import re
8 7 from PIL import Image
9
10 8 from django import forms
9 from django.core.cache import cache
10 from django.core.files.images import get_image_dimensions
11 11 from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
12 12 from django.forms.utils import ErrorList
13 13 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
14 from django.core.files.images import get_image_dimensions
15 from django.core.cache import cache
16 14
17 15 import boards.settings as board_settings
18 import neboard
19 16 from boards import utils
20 17 from boards.abstracts.constants import REGEX_TAGS
18 from boards.abstracts.settingsmanager import get_settings_manager
21 19 from boards.abstracts.sticker_factory import get_attachment_by_alias
22 from boards.abstracts.settingsmanager import get_settings_manager
23 20 from boards.forms.fields import UrlFileField
24 21 from boards.mdx_neboard import formatters
25 22 from boards.models import Attachment
26 23 from boards.models import Tag
27 24 from boards.models.attachment import StickerPack
28 25 from boards.models.attachment.downloaders import download, REGEX_MAGNET
26 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
29 27 from boards.models.post import TITLE_MAX_LENGTH
30 28 from boards.utils import validate_file_size, get_file_mimetype, \
31 29 FILE_EXTENSION_DELIMITER, get_tripcode_from_text
32 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
33 from neboard import settings
34 30
35 31 SECTION_FORMS = 'Forms'
36 32
37 33 POW_HASH_LENGTH = 16
38 34 POW_LIFE_MINUTES = 5
39 35
40 36 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
41 37 REGEX_URL = re.compile(r'^(http|https|ftp):\/\/', re.UNICODE)
42 38
43 39 VETERAN_POSTING_DELAY = 5
44 40
45 41 ATTRIBUTE_PLACEHOLDER = 'placeholder'
46 42 ATTRIBUTE_ROWS = 'rows'
47 43
48 44 LAST_POST_TIME = 'last_post_time'
49 45 LAST_LOGIN_TIME = 'last_login_time'
50 46 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
51 47 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
52 48
53 49 LABEL_TITLE = _('Title')
54 50 LABEL_TEXT = _('Text')
55 51 LABEL_TAG = _('Tag')
56 52 LABEL_SEARCH = _('Search')
57 53 LABEL_FILE = _('File')
58 54 LABEL_DUPLICATES = _('Check for duplicates')
59 55 LABEL_URL = _('Do not download URLs')
60 56
61 57 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
62 58 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
63 59 ERROR_MANY_FILES = 'You can post no more than %(files)d file.'
64 60 ERROR_MANY_FILES_PLURAL = 'You can post no more than %(files)d files.'
65 61 ERROR_DUPLICATES = 'Some files are already present on the board.'
66 62
67 63 TAG_MAX_LENGTH = 20
68 64
69 65 TEXTAREA_ROWS = 4
70 66
71 67 TRIPCODE_DELIM = '##'
72 68
73 69 # TODO Maybe this may be converted into the database table?
74 70 MIMETYPE_EXTENSIONS = {
75 71 'image/jpeg': 'jpeg',
76 72 'image/png': 'png',
77 73 'image/gif': 'gif',
78 74 'video/webm': 'webm',
79 75 'application/pdf': 'pdf',
80 76 'x-diff': 'diff',
81 77 'image/svg+xml': 'svg',
82 78 'application/x-shockwave-flash': 'swf',
83 79 'image/x-ms-bmp': 'bmp',
84 80 'image/bmp': 'bmp',
85 81 }
86 82
87 83 DOWN_MODE_DOWNLOAD = 'DOWNLOAD'
88 84 DOWN_MODE_DOWNLOAD_UNIQUE = 'DOWNLOAD_UNIQUE'
89 85 DOWN_MODE_URL = 'URL'
90 86 DOWN_MODE_TRY = 'TRY'
91 87
92 88
93 89 logger = logging.getLogger('boards.forms')
94 90
95 91
96 92 def get_timezones():
97 93 timezones = []
98 94 for tz in pytz.common_timezones:
99 95 timezones.append((tz, tz),)
100 96 return timezones
101 97
102 98
103 99 class FormatPanel(forms.Textarea):
104 100 """
105 101 Panel for text formatting. Consists of buttons to add different tags to the
106 102 form text area.
107 103 """
108 104
109 105 def render(self, name, value, attrs=None):
110 106 output = '<div id="mark-panel">'
111 107 for formatter in formatters:
112 108 output += '<span class="mark_btn"' + \
113 109 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
114 110 '\', \'' + formatter.format_right + '\')">' + \
115 111 formatter.preview_left + formatter.name + \
116 112 formatter.preview_right + '</span>'
117 113
118 114 output += '</div>'
119 115 output += super(FormatPanel, self).render(name, value, attrs=attrs)
120 116
121 117 return output
122 118
123 119
124 120 class PlainErrorList(ErrorList):
125 121 def __unicode__(self):
126 122 return self.as_text()
127 123
128 124 def as_text(self):
129 125 return ''.join(['(!) %s ' % e for e in self])
130 126
131 127
132 128 class NeboardForm(forms.Form):
133 129 """
134 130 Form with neboard-specific formatting.
135 131 """
136 132 required_css_class = 'required-field'
137 133
138 134 def as_div(self):
139 135 """
140 136 Returns this form rendered as HTML <as_div>s.
141 137 """
142 138
143 139 return self._html_output(
144 140 # TODO Do not show hidden rows in the list here
145 141 normal_row='<div class="form-row">'
146 142 '<div class="form-label">'
147 143 '%(label)s'
148 144 '</div>'
149 145 '<div class="form-input">'
150 146 '%(field)s'
151 147 '</div>'
152 148 '</div>'
153 149 '<div class="form-row">'
154 150 '%(help_text)s'
155 151 '</div>',
156 152 error_row='<div class="form-row">'
157 153 '<div class="form-label"></div>'
158 154 '<div class="form-errors">%s</div>'
159 155 '</div>',
160 156 row_ender='</div>',
161 157 help_text_html='%s',
162 158 errors_on_separate_row=True)
163 159
164 160 def as_json_errors(self):
165 161 errors = []
166 162
167 163 for name, field in list(self.fields.items()):
168 164 if self[name].errors:
169 165 errors.append({
170 166 'field': name,
171 167 'errors': self[name].errors.as_text(),
172 168 })
173 169
174 170 return errors
175 171
176 172
177 173 class PostForm(NeboardForm):
178 174
179 175 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
180 176 label=LABEL_TITLE,
181 177 widget=forms.TextInput(
182 178 attrs={ATTRIBUTE_PLACEHOLDER: 'Title{}tripcode'.format(TRIPCODE_DELIM)}))
183 179 text = forms.CharField(
184 180 widget=FormatPanel(attrs={
185 181 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
186 182 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
187 183 }),
188 184 required=False, label=LABEL_TEXT)
189 185 download_mode = forms.ChoiceField(
190 186 choices=(
191 187 (DOWN_MODE_TRY, _('Download or insert as URLs')),
192 188 (DOWN_MODE_DOWNLOAD, _('Download')),
193 189 (DOWN_MODE_DOWNLOAD_UNIQUE, _('Download and check for uniqueness')),
194 190 (DOWN_MODE_URL, _('Insert as URLs')),
195 191 ),
196 192 initial=DOWN_MODE_TRY,
197 193 label=_('File process mode'))
198 194 file = UrlFileField(required=False, label=LABEL_FILE)
199 195
200 196 # This field is for spam prevention only
201 197 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
202 198 widget=forms.TextInput(attrs={
203 199 'class': 'form-email'}))
204 200 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
205 201
206 202 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
207 203 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
208 204 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
209 205
210 206 session = None
211 207 need_to_ban = False
212 208
213 209 def clean_title(self):
214 210 title = self.cleaned_data['title']
215 211 if title:
216 212 if len(title) > TITLE_MAX_LENGTH:
217 213 raise forms.ValidationError(_('Title must have less than %s '
218 214 'characters') %
219 215 str(TITLE_MAX_LENGTH))
220 216 return title
221 217
222 218 def clean_text(self):
223 219 text = self.cleaned_data['text'].strip()
224 220 if text:
225 221 max_length = board_settings.get_int(SECTION_FORMS, 'MaxTextLength')
226 222 if len(text) > max_length:
227 223 raise forms.ValidationError(_('Text must have less than %s '
228 224 'characters') % str(max_length))
229 225 return text
230 226
231 227 def clean_file(self):
232 228 return self._clean_files(self.cleaned_data['file'])
233 229
234 230 def clean(self):
235 231 cleaned_data = super(PostForm, self).clean()
236 232
237 233 if cleaned_data['email']:
238 234 if board_settings.get_bool(SECTION_FORMS, 'Autoban'):
239 235 self.need_to_ban = True
240 236 raise forms.ValidationError('A human cannot enter a hidden field')
241 237
242 238 if not self.errors:
243 239 self._clean_text_file()
244 240
245 241 limit_speed = board_settings.get_bool(SECTION_FORMS, 'LimitPostingSpeed')
246 242 limit_first = board_settings.get_bool(SECTION_FORMS, 'LimitFirstPosting')
247 243
248 244 settings_manager = get_settings_manager(self)
249 245 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
250 246 pow_difficulty = board_settings.get_int(SECTION_FORMS, 'PowDifficulty')
251 247 if pow_difficulty > 0:
252 248 # PoW-based
253 249 if cleaned_data['timestamp'] \
254 250 and cleaned_data['iteration'] and cleaned_data['guess'] \
255 251 and not settings_manager.get_setting('confirmed_user'):
256 252 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
257 253 else:
258 254 # Time-based
259 255 self._validate_posting_speed()
260 256 settings_manager.set_setting('confirmed_user', True)
261 257 if self.cleaned_data['download_mode'] == DOWN_MODE_DOWNLOAD_UNIQUE:
262 258 self._check_file_duplicates(self.get_files())
263 259
264 260 return cleaned_data
265 261
266 262 def get_files(self):
267 263 """
268 264 Gets file from form or URL.
269 265 """
270 266
271 267 files = []
272 268 for file in self.cleaned_data['file']:
273 269 if isinstance(file, UploadedFile):
274 270 files.append(file)
275 271
276 272 return files
277 273
278 274 def get_file_urls(self):
279 275 files = []
280 276 for file in self.cleaned_data['file']:
281 277 if type(file) == str:
282 278 files.append(file)
283 279
284 280 return files
285 281
286 282 def get_tripcode(self):
287 283 title = self.cleaned_data['title']
288 284 if title is not None and TRIPCODE_DELIM in title:
289 285 tripcode = get_tripcode_from_text(title.split(TRIPCODE_DELIM, maxsplit=1)[1])
290 286 else:
291 287 tripcode = ''
292 288 return tripcode
293 289
294 290 def get_title(self):
295 291 title = self.cleaned_data['title']
296 292 if title is not None and TRIPCODE_DELIM in title:
297 293 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
298 294 else:
299 295 return title
300 296
301 297 def get_images(self):
302 298 return self.cleaned_data.get('stickers', [])
303 299
304 300 def is_subscribe(self):
305 301 return self.cleaned_data['subscribe']
306 302
307 303 def _update_file_extension(self, file):
308 304 if file:
309 305 mimetype = get_file_mimetype(file)
310 306 extension = MIMETYPE_EXTENSIONS.get(mimetype)
311 307 if extension:
312 308 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
313 309 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
314 310
315 311 file.name = new_filename
316 312 else:
317 313 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
318 314
319 315 def _clean_files(self, inputs):
320 316 files = []
321 317
322 318 max_file_count = board_settings.get_int(SECTION_FORMS, 'MaxFileCount')
323 319 if len(inputs) > max_file_count:
324 320 raise forms.ValidationError(
325 321 ungettext_lazy(ERROR_MANY_FILES, ERROR_MANY_FILES,
326 322 max_file_count) % {'files': max_file_count})
327 323 for file_input in inputs:
328 324 if isinstance(file_input, UploadedFile):
329 325 files.append(self._clean_file_file(file_input))
330 326 else:
331 327 files.append(self._clean_file_url(file_input))
332 328
333 329 for file in files:
334 330 self._validate_image_dimensions(file)
335 331
336 332 return files
337 333
338 334 def _validate_image_dimensions(self, file):
339 335 if isinstance(file, UploadedFile):
340 336 mimetype = get_file_mimetype(file)
341 337 if mimetype.split('/')[-1] in FILE_TYPES_IMAGE:
342 338 Image.warnings.simplefilter('error', Image.DecompressionBombWarning)
343 339 try:
344 340 print(get_image_dimensions(file))
345 341 except Exception:
346 342 raise forms.ValidationError('Possible decompression bomb or large image.')
347 343
348 344 def _clean_file_file(self, file):
349 345 validate_file_size(file.size)
350 346 self._update_file_extension(file)
351 347
352 348 return file
353 349
354 350 def _clean_file_url(self, url):
355 351 file = None
356 352
357 353 if url:
358 354 mode = self.cleaned_data['download_mode']
359 355 if mode == DOWN_MODE_URL:
360 356 return url
361 357
362 358 try:
363 359 image = get_attachment_by_alias(url, self.session)
364 360 if image is not None:
365 361 if 'stickers' not in self.cleaned_data:
366 362 self.cleaned_data['stickers'] = []
367 363 self.cleaned_data['stickers'].append(image)
368 364 return
369 365
370 366 if file is None:
371 367 file = self._get_file_from_url(url)
372 368 if not file:
373 369 raise forms.ValidationError(_('Invalid URL'))
374 370 else:
375 371 validate_file_size(file.size)
376 372 self._update_file_extension(file)
377 373 except forms.ValidationError as e:
378 374 # Assume we will get the plain URL instead of a file and save it
379 375 if mode == DOWN_MODE_TRY and (REGEX_URL.match(url) or REGEX_MAGNET.match(url)):
380 376 logger.info('Error in forms: {}'.format(e))
381 377 return url
382 378 else:
383 379 raise e
384 380
385 381 return file
386 382
387 383 def _clean_text_file(self):
388 384 text = self.cleaned_data.get('text')
389 385 file = self.get_files()
390 386 file_url = self.get_file_urls()
391 387 images = self.get_images()
392 388
393 389 if (not text) and (not file) and (not file_url) and len(images) == 0:
394 390 error_message = _('Either text or file must be entered.')
395 391 self._add_general_error(error_message)
396 392
397 393 def _get_cache_key(self, key):
398 394 return '{}_{}'.format(self.session.session_key, key)
399 395
400 396 def _set_session_cache(self, key, value):
401 397 cache.set(self._get_cache_key(key), value)
402 398
403 399 def _get_session_cache(self, key):
404 400 return cache.get(self._get_cache_key(key))
405 401
406 402 def _get_last_post_time(self):
407 403 last = self._get_session_cache(LAST_POST_TIME)
408 404 if last is None:
409 405 last = self.session.get(LAST_POST_TIME)
410 406 return last
411 407
412 408 def _validate_posting_speed(self):
413 409 can_post = True
414 410
415 411 posting_delay = board_settings.get_int(SECTION_FORMS, 'PostingDelay')
416 412
417 413 if board_settings.get_bool(SECTION_FORMS, 'LimitPostingSpeed'):
418 414 now = time.time()
419 415
420 416 current_delay = 0
421 417
422 418 if LAST_POST_TIME not in self.session:
423 419 self.session[LAST_POST_TIME] = now
424 420
425 421 need_delay = True
426 422 else:
427 423 last_post_time = self._get_last_post_time()
428 424 current_delay = int(now - last_post_time)
429 425
430 426 need_delay = current_delay < posting_delay
431 427
432 428 self._set_session_cache(LAST_POST_TIME, now)
433 429
434 430 if need_delay:
435 431 delay = posting_delay - current_delay
436 432 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
437 433 delay) % {'delay': delay}
438 434 self._add_general_error(error_message)
439 435
440 436 can_post = False
441 437
442 438 if can_post:
443 439 self.session[LAST_POST_TIME] = now
444 440 else:
445 441 # Reset the time since posting failed
446 442 self._set_session_cache(LAST_POST_TIME, self.session[LAST_POST_TIME])
447 443
448 444 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
449 445 """
450 446 Gets an file file from URL.
451 447 """
452 448
453 449 try:
454 450 return download(url)
455 451 except forms.ValidationError as e:
456 452 raise e
457 453 except Exception as e:
458 454 raise forms.ValidationError(e)
459 455
460 456 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
461 457 payload = timestamp + message.replace('\r\n', '\n')
462 458 difficulty = board_settings.get_int(SECTION_FORMS, 'PowDifficulty')
463 459 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
464 460 if len(target) < POW_HASH_LENGTH:
465 461 target = '0' * (POW_HASH_LENGTH - len(target)) + target
466 462
467 463 computed_guess = hashlib.sha256((payload + iteration).encode())\
468 464 .hexdigest()[0:POW_HASH_LENGTH]
469 465 if guess != computed_guess or guess > target:
470 466 self._add_general_error(_('Invalid PoW.'))
471 467
472 468 def _check_file_duplicates(self, files):
473 469 for file in files:
474 470 file_hash = utils.get_file_hash(file)
475 471 if Attachment.objects.get_existing_duplicate(file_hash, file):
476 472 self._add_general_error(_(ERROR_DUPLICATES))
477 473
478 474 def _add_general_error(self, message):
479 475 self.add_error('text', forms.ValidationError(message))
480 476
481 477
482 478 class ThreadForm(PostForm):
483 479
484 480 tags = forms.CharField(
485 481 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
486 482 max_length=100, label=_('Tags'), required=True)
487 483 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
488 484 stickerpack = forms.BooleanField(label=_('Sticker Pack'), required=False)
489 485
490 486 def clean_tags(self):
491 487 tags = self.cleaned_data['tags'].strip()
492 488
493 489 if not tags or not REGEX_TAGS.match(tags):
494 490 raise forms.ValidationError(
495 491 _('Inappropriate characters in tags.'))
496 492
497 493 default_tag_name = board_settings.get(SECTION_FORMS, 'DefaultTag')\
498 494 .strip().lower()
499 495
500 496 required_tag_exists = False
501 497 tag_set = set()
502 498 for tag_string in tags.split():
503 499 tag_name = tag_string.strip().lower()
504 500 if tag_name == default_tag_name:
505 501 required_tag_exists = True
506 502 tag, created = Tag.objects.get_or_create_with_alias(
507 503 name=tag_name, required=True)
508 504 else:
509 505 tag, created = Tag.objects.get_or_create_with_alias(name=tag_name)
510 506 tag_set.add(tag)
511 507
512 508 # If this is a new tag, don't check for its parents because nobody
513 509 # added them yet
514 510 if not created:
515 511 tag_set |= set(tag.get_all_parents())
516 512
517 513 for tag in tag_set:
518 514 if tag.required:
519 515 required_tag_exists = True
520 516 break
521 517
522 518 # Use default tag if no section exists
523 519 if not required_tag_exists:
524 520 default_tag, created = Tag.objects.get_or_create_with_alias(
525 521 name=default_tag_name, required=True)
526 522 tag_set.add(default_tag)
527 523
528 524 return tag_set
529 525
530 526 def clean(self):
531 527 cleaned_data = super(ThreadForm, self).clean()
532 528
533 529 return cleaned_data
534 530
535 531 def is_monochrome(self):
536 532 return self.cleaned_data['monochrome']
537 533
538 534 def clean_stickerpack(self):
539 535 stickerpack = self.cleaned_data['stickerpack']
540 536 if stickerpack:
541 537 tripcode = self.get_tripcode()
542 538 if not tripcode:
543 539 raise forms.ValidationError(_(
544 540 'Tripcode should be specified to own a stickerpack.'))
545 541 title = self.get_title()
546 542 if not title:
547 543 raise forms.ValidationError(_(
548 544 'Title should be specified as a stickerpack name.'))
549 545 if not REGEX_TAGS.match(title):
550 546 raise forms.ValidationError(_('Inappropriate sticker pack name.'))
551 547
552 548 existing_pack = StickerPack.objects.filter(name=title).first()
553 549 if existing_pack:
554 550 if existing_pack.tripcode != tripcode:
555 551 raise forms.ValidationError(_(
556 552 'A sticker pack with this name already exists and is'
557 553 ' owned by another tripcode.'))
558 554 if not existing_pack.tripcode:
559 555 raise forms.ValidationError(_(
560 556 'This sticker pack can only be updated by an '
561 557 'administrator.'))
562 558
563 559 return stickerpack
564 560
565 561 def is_stickerpack(self):
566 562 return self.cleaned_data['stickerpack']
567 563
568 564
569 565 class SettingsForm(NeboardForm):
570 566
571 567 theme = forms.ChoiceField(
572 568 choices=board_settings.get_list_dict('View', 'Themes'),
573 569 label=_('Theme'))
574 570 image_viewer = forms.ChoiceField(
575 571 choices=board_settings.get_list_dict('View', 'ImageViewers'),
576 572 label=_('Image view mode'))
577 573 username = forms.CharField(label=_('User name'), required=False)
578 574 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
579 575
580 576 def clean_username(self):
581 577 username = self.cleaned_data['username']
582 578
583 579 if username and not REGEX_USERNAMES.match(username):
584 580 raise forms.ValidationError(_('Inappropriate characters.'))
585 581
586 582 return username
587 583
588 584
589 585 class SearchForm(NeboardForm):
590 586 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