##// END OF EJS Templates
Download attached filed to the post during sync
neko259 -
r1511:ea51d39c decentral
parent child Browse files
Show More
@@ -1,111 +1,124 b''
1 1 from django.contrib import admin
2 2 from django.utils.translation import ugettext_lazy as _
3 3 from django.core.urlresolvers import reverse
4 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair
4 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair, GlobalId
5 5
6 6
7 7 @admin.register(Post)
8 8 class PostAdmin(admin.ModelAdmin):
9 9
10 10 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images')
11 11 list_filter = ('pub_time',)
12 12 search_fields = ('id', 'title', 'text', 'poster_ip')
13 13 exclude = ('referenced_posts', 'refmap')
14 14 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
15 15 'attachments', 'uid', 'url', 'pub_time', 'opening')
16 16
17 17 def ban_poster(self, request, queryset):
18 18 bans = 0
19 19 for post in queryset:
20 20 poster_ip = post.poster_ip
21 21 ban, created = Ban.objects.get_or_create(ip=poster_ip)
22 22 if created:
23 23 bans += 1
24 24 self.message_user(request, _('{} posters were banned').format(bans))
25 25
26 26 def ban_with_hiding(self, request, queryset):
27 27 bans = 0
28 28 hidden = 0
29 29 for post in queryset:
30 30 poster_ip = post.poster_ip
31 31 ban, created = Ban.objects.get_or_create(ip=poster_ip)
32 32 if created:
33 33 bans += 1
34 34 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
35 35 hidden += posts.count()
36 36 posts.update(hidden=True)
37 37 self.message_user(request, _('{} posters were banned, {} messages were hidden').format(bans, hidden))
38 38
39 39 def linked_images(self, obj: Post):
40 40 images = obj.images.all()
41 41 image_urls = ['<a href="{}">{}</a>'.format(reverse('admin:%s_%s_change' %(image._meta.app_label, image._meta.model_name), args=[image.id]), image.hash) for image in images]
42 42 return ', '.join(image_urls)
43 43 linked_images.allow_tags = True
44 44
45 45
46 46 actions = ['ban_poster', 'ban_with_hiding']
47 47
48 48
49 49 @admin.register(Tag)
50 50 class TagAdmin(admin.ModelAdmin):
51 51
52 52 def thread_count(self, obj: Tag) -> int:
53 53 return obj.get_thread_count()
54 54
55 55 def display_children(self, obj: Tag):
56 56 return ', '.join([str(child) for child in obj.get_children().all()])
57 57
58 58 def save_model(self, request, obj, form, change):
59 59 super().save_model(request, obj, form, change)
60 60 for thread in obj.get_threads().all():
61 61 thread.refresh_tags()
62 62 list_display = ('name', 'thread_count', 'display_children')
63 63 search_fields = ('name',)
64 64
65 65
66 66 @admin.register(Thread)
67 67 class ThreadAdmin(admin.ModelAdmin):
68 68
69 69 def title(self, obj: Thread) -> str:
70 70 return obj.get_opening_post().get_title()
71 71
72 72 def reply_count(self, obj: Thread) -> int:
73 73 return obj.get_reply_count()
74 74
75 75 def ip(self, obj: Thread):
76 76 return obj.get_opening_post().poster_ip
77 77
78 78 def display_tags(self, obj: Thread):
79 79 return ', '.join([str(tag) for tag in obj.get_tags().all()])
80 80
81 81 def op(self, obj: Thread):
82 82 return obj.get_opening_post_id()
83 83
84 84 # Save parent tags when editing tags
85 85 def save_related(self, request, form, formsets, change):
86 86 super().save_related(request, form, formsets, change)
87 87 form.instance.refresh_tags()
88 88 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
89 89 'display_tags')
90 90 list_filter = ('bump_time', 'status')
91 91 search_fields = ('id', 'title')
92 92 filter_horizontal = ('tags',)
93 93
94 94
95 95 @admin.register(KeyPair)
96 96 class KeyPairAdmin(admin.ModelAdmin):
97 97 list_display = ('public_key', 'primary')
98 98 list_filter = ('primary',)
99 99 search_fields = ('public_key',)
100 100
101 101
102 102 @admin.register(Ban)
103 103 class BanAdmin(admin.ModelAdmin):
104 104 list_display = ('ip', 'can_read')
105 105 list_filter = ('can_read',)
106 106 search_fields = ('ip',)
107 107
108 108
109 109 @admin.register(Banner)
110 110 class BannerAdmin(admin.ModelAdmin):
111 111 list_display = ('title', 'text')
112
113
114 @admin.register(PostImage)
115 class PostImageAdmin(admin.ModelAdmin):
116 search_fields = ('alias',)
117
118
119 @admin.register(GlobalId)
120 class GlobalIdAdmin(admin.ModelAdmin):
121 def is_linked(self, obj):
122 return Post.objects.filter(global_id=obj).exists()
123
124 list_display = ('__str__', 'is_linked',) No newline at end of file
@@ -1,469 +1,464 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 from boards.models.attachment.downloaders import Downloader
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 32
33 33 VETERAN_POSTING_DELAY = 5
34 34
35 35 ATTRIBUTE_PLACEHOLDER = 'placeholder'
36 36 ATTRIBUTE_ROWS = 'rows'
37 37
38 38 LAST_POST_TIME = 'last_post_time'
39 39 LAST_LOGIN_TIME = 'last_login_time'
40 40 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
41 41 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
42 42
43 43 LABEL_TITLE = _('Title')
44 44 LABEL_TEXT = _('Text')
45 45 LABEL_TAG = _('Tag')
46 46 LABEL_SEARCH = _('Search')
47 47
48 48 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
49 49 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
50 50
51 51 TAG_MAX_LENGTH = 20
52 52
53 53 TEXTAREA_ROWS = 4
54 54
55 55 TRIPCODE_DELIM = '#'
56 56
57 57 # TODO Maybe this may be converted into the database table?
58 58 MIMETYPE_EXTENSIONS = {
59 59 'image/jpeg': 'jpeg',
60 60 'image/png': 'png',
61 61 'image/gif': 'gif',
62 62 'video/webm': 'webm',
63 63 'application/pdf': 'pdf',
64 64 'x-diff': 'diff',
65 65 'image/svg+xml': 'svg',
66 66 'application/x-shockwave-flash': 'swf',
67 67 'image/x-ms-bmp': 'bmp',
68 68 'image/bmp': 'bmp',
69 69 }
70 70
71 71
72 72 def get_timezones():
73 73 timezones = []
74 74 for tz in pytz.common_timezones:
75 75 timezones.append((tz, tz),)
76 76 return timezones
77 77
78 78
79 79 class FormatPanel(forms.Textarea):
80 80 """
81 81 Panel for text formatting. Consists of buttons to add different tags to the
82 82 form text area.
83 83 """
84 84
85 85 def render(self, name, value, attrs=None):
86 86 output = '<div id="mark-panel">'
87 87 for formatter in formatters:
88 88 output += '<span class="mark_btn"' + \
89 89 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
90 90 '\', \'' + formatter.format_right + '\')">' + \
91 91 formatter.preview_left + formatter.name + \
92 92 formatter.preview_right + '</span>'
93 93
94 94 output += '</div>'
95 95 output += super(FormatPanel, self).render(name, value, attrs=attrs)
96 96
97 97 return output
98 98
99 99
100 100 class PlainErrorList(ErrorList):
101 101 def __unicode__(self):
102 102 return self.as_text()
103 103
104 104 def as_text(self):
105 105 return ''.join(['(!) %s ' % e for e in self])
106 106
107 107
108 108 class NeboardForm(forms.Form):
109 109 """
110 110 Form with neboard-specific formatting.
111 111 """
112 112
113 113 def as_div(self):
114 114 """
115 115 Returns this form rendered as HTML <as_div>s.
116 116 """
117 117
118 118 return self._html_output(
119 119 # TODO Do not show hidden rows in the list here
120 120 normal_row='<div class="form-row">'
121 121 '<div class="form-label">'
122 122 '%(label)s'
123 123 '</div>'
124 124 '<div class="form-input">'
125 125 '%(field)s'
126 126 '</div>'
127 127 '</div>'
128 128 '<div class="form-row">'
129 129 '%(help_text)s'
130 130 '</div>',
131 131 error_row='<div class="form-row">'
132 132 '<div class="form-label"></div>'
133 133 '<div class="form-errors">%s</div>'
134 134 '</div>',
135 135 row_ender='</div>',
136 136 help_text_html='%s',
137 137 errors_on_separate_row=True)
138 138
139 139 def as_json_errors(self):
140 140 errors = []
141 141
142 142 for name, field in list(self.fields.items()):
143 143 if self[name].errors:
144 144 errors.append({
145 145 'field': name,
146 146 'errors': self[name].errors.as_text(),
147 147 })
148 148
149 149 return errors
150 150
151 151
152 152 class PostForm(NeboardForm):
153 153
154 154 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
155 155 label=LABEL_TITLE,
156 156 widget=forms.TextInput(
157 157 attrs={ATTRIBUTE_PLACEHOLDER:
158 158 'test#tripcode'}))
159 159 text = forms.CharField(
160 160 widget=FormatPanel(attrs={
161 161 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
162 162 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
163 163 }),
164 164 required=False, label=LABEL_TEXT)
165 165 file = forms.FileField(required=False, label=_('File'),
166 166 widget=forms.ClearableFileInput(
167 167 attrs={'accept': 'file/*'}))
168 168 file_url = forms.CharField(required=False, label=_('File URL'),
169 169 widget=forms.TextInput(
170 170 attrs={ATTRIBUTE_PLACEHOLDER:
171 171 'http://example.com/image.png'}))
172 172
173 173 # This field is for spam prevention only
174 174 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
175 175 widget=forms.TextInput(attrs={
176 176 'class': 'form-email'}))
177 177 threads = forms.CharField(required=False, label=_('Additional threads'),
178 178 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
179 179 '123 456 789'}))
180 180
181 181 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
182 182 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
183 183 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
184 184
185 185 session = None
186 186 need_to_ban = False
187 187 image = None
188 188
189 189 def _update_file_extension(self, file):
190 190 if file:
191 191 mimetype = get_file_mimetype(file)
192 192 extension = MIMETYPE_EXTENSIONS.get(mimetype)
193 193 if extension:
194 194 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
195 195 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
196 196
197 197 file.name = new_filename
198 198 else:
199 199 logger = logging.getLogger('boards.forms.extension')
200 200
201 201 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
202 202
203 203 def clean_title(self):
204 204 title = self.cleaned_data['title']
205 205 if title:
206 206 if len(title) > TITLE_MAX_LENGTH:
207 207 raise forms.ValidationError(_('Title must have less than %s '
208 208 'characters') %
209 209 str(TITLE_MAX_LENGTH))
210 210 return title
211 211
212 212 def clean_text(self):
213 213 text = self.cleaned_data['text'].strip()
214 214 if text:
215 215 max_length = board_settings.get_int('Forms', 'MaxTextLength')
216 216 if len(text) > max_length:
217 217 raise forms.ValidationError(_('Text must have less than %s '
218 218 'characters') % str(max_length))
219 219 return text
220 220
221 221 def clean_file(self):
222 222 file = self.cleaned_data['file']
223 223
224 224 if file:
225 225 validate_file_size(file.size)
226 226 self._update_file_extension(file)
227 227
228 228 return file
229 229
230 230 def clean_file_url(self):
231 231 url = self.cleaned_data['file_url']
232 232
233 233 file = None
234 234
235 235 if url:
236 236 file = get_image_by_alias(url, self.session)
237 237 self.image = file
238 238
239 239 if file is not None:
240 240 return
241 241
242 242 if file is None:
243 243 file = self._get_file_from_url(url)
244 244 if not file:
245 245 raise forms.ValidationError(_('Invalid URL'))
246 246 else:
247 247 validate_file_size(file.size)
248 248 self._update_file_extension(file)
249 249
250 250 return file
251 251
252 252 def clean_threads(self):
253 253 threads_str = self.cleaned_data['threads']
254 254
255 255 if len(threads_str) > 0:
256 256 threads_id_list = threads_str.split(' ')
257 257
258 258 threads = list()
259 259
260 260 for thread_id in threads_id_list:
261 261 try:
262 262 thread = Post.objects.get(id=int(thread_id))
263 263 if not thread.is_opening() or thread.get_thread().is_archived():
264 264 raise ObjectDoesNotExist()
265 265 threads.append(thread)
266 266 except (ObjectDoesNotExist, ValueError):
267 267 raise forms.ValidationError(_('Invalid additional thread list'))
268 268
269 269 return threads
270 270
271 271 def clean(self):
272 272 cleaned_data = super(PostForm, self).clean()
273 273
274 274 if cleaned_data['email']:
275 275 self.need_to_ban = True
276 276 raise forms.ValidationError('A human cannot enter a hidden field')
277 277
278 278 if not self.errors:
279 279 self._clean_text_file()
280 280
281 281 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
282 282
283 283 settings_manager = get_settings_manager(self)
284 284 if not self.errors and limit_speed and not settings_manager.get_setting('confirmed_user'):
285 285 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
286 286 if pow_difficulty > 0:
287 287 # Limit only first post
288 288 if cleaned_data['timestamp'] \
289 289 and cleaned_data['iteration'] and cleaned_data['guess'] \
290 290 and not settings_manager.get_setting('confirmed_user'):
291 291 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
292 292 else:
293 293 # Limit every post
294 294 self._validate_posting_speed()
295 295 settings_manager.set_setting('confirmed_user', True)
296 296
297 297
298 298 return cleaned_data
299 299
300 300 def get_file(self):
301 301 """
302 302 Gets file from form or URL.
303 303 """
304 304
305 305 file = self.cleaned_data['file']
306 306 return file or self.cleaned_data['file_url']
307 307
308 308 def get_tripcode(self):
309 309 title = self.cleaned_data['title']
310 310 if title is not None and TRIPCODE_DELIM in title:
311 311 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
312 312 tripcode = hashlib.md5(code.encode()).hexdigest()
313 313 else:
314 314 tripcode = ''
315 315 return tripcode
316 316
317 317 def get_title(self):
318 318 title = self.cleaned_data['title']
319 319 if title is not None and TRIPCODE_DELIM in title:
320 320 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
321 321 else:
322 322 return title
323 323
324 324 def get_images(self):
325 325 if self.image:
326 326 return [self.image]
327 327 else:
328 328 return []
329 329
330 330 def _clean_text_file(self):
331 331 text = self.cleaned_data.get('text')
332 332 file = self.get_file()
333 333 images = self.get_images()
334 334
335 335 if (not text) and (not file) and len(images) == 0:
336 336 error_message = _('Either text or file must be entered.')
337 337 self._errors['text'] = self.error_class([error_message])
338 338
339 339 def _validate_posting_speed(self):
340 340 can_post = True
341 341
342 342 posting_delay = settings.POSTING_DELAY
343 343
344 344 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
345 345 now = time.time()
346 346
347 347 current_delay = 0
348 348
349 349 if LAST_POST_TIME not in self.session:
350 350 self.session[LAST_POST_TIME] = now
351 351
352 352 need_delay = True
353 353 else:
354 354 last_post_time = self.session.get(LAST_POST_TIME)
355 355 current_delay = int(now - last_post_time)
356 356
357 357 need_delay = current_delay < posting_delay
358 358
359 359 if need_delay:
360 360 delay = posting_delay - current_delay
361 361 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
362 362 delay) % {'delay': delay}
363 363 self._errors['text'] = self.error_class([error_message])
364 364
365 365 can_post = False
366 366
367 367 if can_post:
368 368 self.session[LAST_POST_TIME] = now
369 369
370 370 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
371 371 """
372 372 Gets an file file from URL.
373 373 """
374 374
375 375 img_temp = None
376 376
377 377 try:
378 for downloader in Downloader.__subclasses__():
379 if downloader.handles(url):
380 return downloader.download(url)
381 # If nobody of the specific downloaders handles this, use generic
382 # one
383 return Downloader.download(url)
378 download(url)
384 379 except forms.ValidationError as e:
385 380 raise e
386 381 except Exception as e:
387 382 raise forms.ValidationError(e)
388 383
389 384 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
390 385 post_time = timezone.datetime.fromtimestamp(
391 386 int(timestamp[:-3]), tz=timezone.get_current_timezone())
392 387
393 388 payload = timestamp + message.replace('\r\n', '\n')
394 389 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
395 390 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
396 391 if len(target) < POW_HASH_LENGTH:
397 392 target = '0' * (POW_HASH_LENGTH - len(target)) + target
398 393
399 394 computed_guess = hashlib.sha256((payload + iteration).encode())\
400 395 .hexdigest()[0:POW_HASH_LENGTH]
401 396 if guess != computed_guess or guess > target:
402 397 self._errors['text'] = self.error_class(
403 398 [_('Invalid PoW.')])
404 399
405 400
406 401
407 402 class ThreadForm(PostForm):
408 403
409 404 tags = forms.CharField(
410 405 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
411 406 max_length=100, label=_('Tags'), required=True)
412 407 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
413 408
414 409 def clean_tags(self):
415 410 tags = self.cleaned_data['tags'].strip()
416 411
417 412 if not tags or not REGEX_TAGS.match(tags):
418 413 raise forms.ValidationError(
419 414 _('Inappropriate characters in tags.'))
420 415
421 416 required_tag_exists = False
422 417 tag_set = set()
423 418 for tag_string in tags.split():
424 419 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
425 420 tag_set.add(tag)
426 421
427 422 # If this is a new tag, don't check for its parents because nobody
428 423 # added them yet
429 424 if not created:
430 425 tag_set |= set(tag.get_all_parents())
431 426
432 427 for tag in tag_set:
433 428 if tag.required:
434 429 required_tag_exists = True
435 430 break
436 431
437 432 if not required_tag_exists:
438 433 raise forms.ValidationError(
439 434 _('Need at least one section.'))
440 435
441 436 return tag_set
442 437
443 438 def clean(self):
444 439 cleaned_data = super(ThreadForm, self).clean()
445 440
446 441 return cleaned_data
447 442
448 443 def is_monochrome(self):
449 444 return self.cleaned_data['monochrome']
450 445
451 446
452 447 class SettingsForm(NeboardForm):
453 448
454 449 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
455 450 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
456 451 username = forms.CharField(label=_('User name'), required=False)
457 452 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
458 453
459 454 def clean_username(self):
460 455 username = self.cleaned_data['username']
461 456
462 457 if username and not REGEX_USERNAMES.match(username):
463 458 raise forms.ValidationError(_('Inappropriate characters.'))
464 459
465 460 return username
466 461
467 462
468 463 class SearchForm(NeboardForm):
469 464 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,79 +1,80 b''
1 1 import re
2 2 import xml.etree.ElementTree as ET
3 3
4 4 import httplib2
5 5 from django.core.management import BaseCommand
6 6
7 7 from boards.models import GlobalId
8 8 from boards.models.post.sync import SyncManager
9 9
10 10 __author__ = 'neko259'
11 11
12 12
13 13 REGEX_GLOBAL_ID = re.compile(r'(\w+)::([\w\+/]+)::(\d+)')
14 14
15 15
16 16 class Command(BaseCommand):
17 17 help = 'Send a sync or get request to the server.'
18 18
19 19 def add_arguments(self, parser):
20 20 parser.add_argument('url', type=str, help='Server root url')
21 21 parser.add_argument('--global-id', type=str, default='',
22 22 help='Post global ID')
23 23
24 24 def handle(self, *args, **options):
25 25 url = options.get('url')
26 26
27 27 pull_url = url + 'api/sync/pull/'
28 28 get_url = url + 'api/sync/get/'
29 file_url = url[:-1]
29 30
30 31 global_id_str = options.get('global_id')
31 32 if global_id_str:
32 33 match = REGEX_GLOBAL_ID.match(global_id_str)
33 34 if match:
34 35 key_type = match.group(1)
35 36 key = match.group(2)
36 37 local_id = match.group(3)
37 38
38 39 global_id = GlobalId(key_type=key_type, key=key,
39 40 local_id=local_id)
40 41
41 42 xml = GlobalId.objects.generate_request_get([global_id])
42 43 # body = urllib.parse.urlencode(data)
43 44 h = httplib2.Http()
44 45 response, content = h.request(get_url, method="POST", body=xml)
45 46
46 SyncManager.parse_response_get(content)
47 SyncManager.parse_response_get(content, file_url)
47 48 else:
48 49 raise Exception('Invalid global ID')
49 50 else:
50 51 h = httplib2.Http()
51 52 xml = GlobalId.objects.generate_request_pull()
52 53 response, content = h.request(pull_url, method="POST", body=xml)
53 54
54 55 print(content.decode() + '\n')
55 56
56 57 root = ET.fromstring(content)
57 58 status = root.findall('status')[0].text
58 59 if status == 'success':
59 60 ids_to_sync = list()
60 61
61 62 models = root.findall('models')[0]
62 63 for model in models:
63 64 global_id, exists = GlobalId.from_xml_element(model)
64 65 if not exists:
65 66 print(global_id)
66 67 ids_to_sync.append(global_id)
67 68 print()
68 69
69 70 if len(ids_to_sync) > 0:
70 71 xml = GlobalId.objects.generate_request_get(ids_to_sync)
71 72 # body = urllib.parse.urlencode(data)
72 73 h = httplib2.Http()
73 74 response, content = h.request(get_url, method="POST", body=xml)
74 75
75 SyncManager.parse_response_get(content)
76 SyncManager.parse_response_get(content, file_url)
76 77 else:
77 78 print('Nothing to get, everything synced')
78 79 else:
79 80 raise Exception('Invalid response status')
@@ -1,70 +1,79 b''
1 1 import os
2 2 import re
3 3
4 4 from django.core.files.uploadedfile import SimpleUploadedFile, \
5 5 TemporaryUploadedFile
6 6 from pytube import YouTube
7 7 import requests
8 8
9 9 from boards.utils import validate_file_size
10 10
11 11 YOUTUBE_VIDEO_FORMAT = 'webm'
12 12
13 13 HTTP_RESULT_OK = 200
14 14
15 15 HEADER_CONTENT_LENGTH = 'content-length'
16 16 HEADER_CONTENT_TYPE = 'content-type'
17 17
18 18 FILE_DOWNLOAD_CHUNK_BYTES = 200000
19 19
20 20 YOUTUBE_URL = re.compile(r'https?://((www\.)?youtube\.com/watch\?v=|youtu.be/)\w+')
21 21
22 22
23 23 class Downloader:
24 24 @staticmethod
25 25 def handles(url: str) -> bool:
26 26 return False
27 27
28 28 @staticmethod
29 29 def download(url: str):
30 30 # Verify content headers
31 31 response_head = requests.head(url, verify=False)
32 32 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
33 33 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
34 34 if length_header:
35 35 length = int(length_header)
36 36 validate_file_size(length)
37 37 # Get the actual content into memory
38 38 response = requests.get(url, verify=False, stream=True)
39 39
40 40 # Download file, stop if the size exceeds limit
41 41 size = 0
42 42
43 43 # Set a dummy file name that will be replaced
44 44 # anyway, just keep the valid extension
45 45 filename = 'file.' + content_type.split('/')[1]
46 46
47 47 file = TemporaryUploadedFile(filename, content_type, 0, None, None)
48 48 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
49 49 size += len(chunk)
50 50 validate_file_size(size)
51 51 file.write(chunk)
52 52
53 53 if response.status_code == HTTP_RESULT_OK:
54 54 return file
55 55
56 56
57 def download(url):
58 for downloader in Downloader.__subclasses__():
59 if downloader.handles(url):
60 return downloader.download(url)
61 # If nobody of the specific downloaders handles this, use generic
62 # one
63 return Downloader.download(url)
64
65
57 66 class YouTubeDownloader(Downloader):
58 67 @staticmethod
59 68 def download(url: str):
60 69 yt = YouTube()
61 70 yt.from_url(url)
62 71 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
63 72 if len(videos) > 0:
64 73 video = videos[0]
65 74 return Downloader.download(video.url)
66 75
67 76 @staticmethod
68 77 def handles(url: str) -> bool:
69 78 return YOUTUBE_URL.match(url)
70 79
@@ -1,455 +1,453 b''
1 1 import logging
2 import re
3 2 import uuid
4 3
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.urlresolvers import reverse
7 from django.db import models
8 from django.db.models import TextField, QuerySet
9 from django.template.defaultfilters import truncatewords, striptags
10 from django.template.loader import render_to_string
11 from django.utils import timezone
12 from django.dispatch import receiver
13 from django.db.models.signals import pre_save, post_save
14
4 import re
15 5 from boards import settings
16 6 from boards.abstracts.tripcode import Tripcode
17 7 from boards.mdx_neboard import get_parser
18 8 from boards.models import PostImage, Attachment, KeyPair, GlobalId
19 9 from boards.models.base import Viewable
20 10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 11 from boards.models.post.manager import PostManager
22 12 from boards.models.user import Notification
13 from django.core.exceptions import ObjectDoesNotExist
14 from django.core.urlresolvers import reverse
15 from django.db import models
16 from django.db.models import TextField, QuerySet
17 from django.db.models.signals import pre_save, post_save, pre_delete, \
18 post_delete
19 from django.dispatch import receiver
20 from django.template.defaultfilters import truncatewords, striptags
21 from django.template.loader import render_to_string
22 from django.utils import timezone
23 23
24 24 CSS_CLS_HIDDEN_POST = 'hidden_post'
25 25 CSS_CLS_DEAD_POST = 'dead_post'
26 26 CSS_CLS_ARCHIVE_POST = 'archive_post'
27 27 CSS_CLS_POST = 'post'
28 28 CSS_CLS_MONOCHROME = 'monochrome'
29 29
30 30 TITLE_MAX_WORDS = 10
31 31
32 32 APP_LABEL_BOARDS = 'boards'
33 33
34 34 BAN_REASON_AUTO = 'Auto'
35 35
36 36 IMAGE_THUMB_SIZE = (200, 150)
37 37
38 38 TITLE_MAX_LENGTH = 200
39 39
40 40 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
41 41 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
42 42 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
43 43 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
44 44
45 45 PARAMETER_TRUNCATED = 'truncated'
46 46 PARAMETER_TAG = 'tag'
47 47 PARAMETER_OFFSET = 'offset'
48 48 PARAMETER_DIFF_TYPE = 'type'
49 49 PARAMETER_CSS_CLASS = 'css_class'
50 50 PARAMETER_THREAD = 'thread'
51 51 PARAMETER_IS_OPENING = 'is_opening'
52 52 PARAMETER_POST = 'post'
53 53 PARAMETER_OP_ID = 'opening_post_id'
54 54 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
55 55 PARAMETER_REPLY_LINK = 'reply_link'
56 56 PARAMETER_NEED_OP_DATA = 'need_op_data'
57 57
58 58 POST_VIEW_PARAMS = (
59 59 'need_op_data',
60 60 'reply_link',
61 61 'need_open_link',
62 62 'truncated',
63 63 'mode_tree',
64 64 'perms',
65 65 'tree_depth',
66 66 )
67 67
68 68
69 69 class Post(models.Model, Viewable):
70 70 """A post is a message."""
71 71
72 72 objects = PostManager()
73 73
74 74 class Meta:
75 75 app_label = APP_LABEL_BOARDS
76 76 ordering = ('id',)
77 77
78 78 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
79 79 pub_time = models.DateTimeField()
80 80 text = TextField(blank=True, null=True)
81 81 _text_rendered = TextField(blank=True, null=True, editable=False)
82 82
83 83 images = models.ManyToManyField(PostImage, null=True, blank=True,
84 84 related_name='post_images', db_index=True)
85 85 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
86 86 related_name='attachment_posts')
87 87
88 88 poster_ip = models.GenericIPAddressField()
89 89
90 90 # TODO This field can be removed cause UID is used for update now
91 91 last_edit_time = models.DateTimeField()
92 92
93 93 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
94 94 null=True,
95 95 blank=True, related_name='refposts',
96 96 db_index=True)
97 97 refmap = models.TextField(null=True, blank=True)
98 98 threads = models.ManyToManyField('Thread', db_index=True,
99 99 related_name='multi_replies')
100 100 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
101 101
102 102 url = models.TextField()
103 103 uid = models.TextField(db_index=True)
104 104
105 105 # Global ID with author key. If the message was downloaded from another
106 106 # server, this indicates the server.
107 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
107 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
108 on_delete=models.CASCADE)
108 109
109 110 tripcode = models.CharField(max_length=50, blank=True, default='')
110 111 opening = models.BooleanField(db_index=True)
111 112 hidden = models.BooleanField(default=False)
112 113
113 114 def __str__(self):
114 115 return 'P#{}/{}'.format(self.id, self.get_title())
115 116
116 117 def get_referenced_posts(self):
117 118 threads = self.get_threads().all()
118 119 return self.referenced_posts.filter(threads__in=threads)\
119 120 .order_by('pub_time').distinct().all()
120 121
121 122 def get_title(self) -> str:
122 123 return self.title
123 124
124 125 def get_title_or_text(self):
125 126 title = self.get_title()
126 127 if not title:
127 128 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
128 129
129 130 return title
130 131
131 132 def build_refmap(self) -> None:
132 133 """
133 134 Builds a replies map string from replies list. This is a cache to stop
134 135 the server from recalculating the map on every post show.
135 136 """
136 137
137 138 post_urls = [refpost.get_link_view()
138 139 for refpost in self.referenced_posts.all()]
139 140
140 141 self.refmap = ', '.join(post_urls)
141 142
142 143 def is_referenced(self) -> bool:
143 144 return self.refmap and len(self.refmap) > 0
144 145
145 146 def is_opening(self) -> bool:
146 147 """
147 148 Checks if this is an opening post or just a reply.
148 149 """
149 150
150 151 return self.opening
151 152
152 153 def get_absolute_url(self, thread=None):
153 154 url = None
154 155
155 156 if thread is None:
156 157 thread = self.get_thread()
157 158
158 159 # Url is cached only for the "main" thread. When getting url
159 160 # for other threads, do it manually.
160 161 if self.url:
161 162 url = self.url
162 163
163 164 if url is None:
164 165 opening = self.is_opening()
165 166 opening_id = self.id if opening else thread.get_opening_post_id()
166 167 url = reverse('thread', kwargs={'post_id': opening_id})
167 168 if not opening:
168 169 url += '#' + str(self.id)
169 170
170 171 return url
171 172
172 173 def get_thread(self):
173 174 return self.thread
174 175
175 176 def get_thread_id(self):
176 177 return self.thread_id
178
177 179 def get_threads(self) -> QuerySet:
178 180 """
179 181 Gets post's thread.
180 182 """
181 183
182 184 return self.threads
183 185
184 186 def get_view(self, *args, **kwargs) -> str:
185 187 """
186 188 Renders post's HTML view. Some of the post params can be passed over
187 189 kwargs for the means of caching (if we view the thread, some params
188 190 are same for every post and don't need to be computed over and over.
189 191 """
190 192
191 193 thread = self.get_thread()
192 194
193 195 css_classes = [CSS_CLS_POST]
194 196 if thread.is_archived():
195 197 css_classes.append(CSS_CLS_ARCHIVE_POST)
196 198 elif not thread.can_bump():
197 199 css_classes.append(CSS_CLS_DEAD_POST)
198 200 if self.is_hidden():
199 201 css_classes.append(CSS_CLS_HIDDEN_POST)
200 202 if thread.is_monochrome():
201 203 css_classes.append(CSS_CLS_MONOCHROME)
202 204
203 205 params = dict()
204 206 for param in POST_VIEW_PARAMS:
205 207 if param in kwargs:
206 208 params[param] = kwargs[param]
207 209
208 210 params.update({
209 211 PARAMETER_POST: self,
210 212 PARAMETER_IS_OPENING: self.is_opening(),
211 213 PARAMETER_THREAD: thread,
212 214 PARAMETER_CSS_CLASS: ' '.join(css_classes),
213 215 })
214 216
215 217 return render_to_string('boards/post.html', params)
216 218
217 219 def get_search_view(self, *args, **kwargs):
218 220 return self.get_view(need_op_data=True, *args, **kwargs)
219 221
220 222 def get_first_image(self) -> PostImage:
221 223 return self.images.earliest('id')
222 224
223 def delete(self, using=None):
224 """
225 Deletes all post images and the post itself.
226 """
227
228 for image in self.images.all():
229 image_refs_count = image.post_images.count()
230 if image_refs_count == 1:
231 image.delete()
232
233 for attachment in self.attachments.all():
234 attachment_refs_count = attachment.attachment_posts.count()
235 if attachment_refs_count == 1:
236 attachment.delete()
237
238 if self.global_id:
239 self.global_id.delete()
240
241 thread = self.get_thread()
242 thread.last_edit_time = timezone.now()
243 thread.save()
244
245 super(Post, self).delete(using)
246
247 logging.getLogger('boards.post.delete').info(
248 'Deleted post {}'.format(self))
249
250 225 def set_global_id(self, key_pair=None):
251 226 """
252 227 Sets global id based on the given key pair. If no key pair is given,
253 228 default one is used.
254 229 """
255 230
256 231 if key_pair:
257 232 key = key_pair
258 233 else:
259 234 try:
260 235 key = KeyPair.objects.get(primary=True)
261 236 except KeyPair.DoesNotExist:
262 237 # Do not update the global id because there is no key defined
263 238 return
264 239 global_id = GlobalId(key_type=key.key_type,
265 240 key=key.public_key,
266 241 local_id=self.id)
267 242 global_id.save()
268 243
269 244 self.global_id = global_id
270 245
271 246 self.save(update_fields=['global_id'])
272 247
273 248 def get_pub_time_str(self):
274 249 return str(self.pub_time)
275 250
276 251 def get_replied_ids(self):
277 252 """
278 253 Gets ID list of the posts that this post replies.
279 254 """
280 255
281 256 raw_text = self.get_raw_text()
282 257
283 258 local_replied = REGEX_REPLY.findall(raw_text)
284 259 global_replied = []
285 260 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
286 261 key_type = match[0]
287 262 key = match[1]
288 263 local_id = match[2]
289 264
290 265 try:
291 266 global_id = GlobalId.objects.get(key_type=key_type,
292 267 key=key, local_id=local_id)
293 268 for post in Post.objects.filter(global_id=global_id).only('id'):
294 269 global_replied.append(post.id)
295 270 except GlobalId.DoesNotExist:
296 271 pass
297 272 return local_replied + global_replied
298 273
299 274 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
300 275 include_last_update=False) -> str:
301 276 """
302 277 Gets post HTML or JSON data that can be rendered on a page or used by
303 278 API.
304 279 """
305 280
306 281 return get_exporter(format_type).export(self, request,
307 282 include_last_update)
308 283
309 284 def notify_clients(self, recursive=True):
310 285 """
311 286 Sends post HTML data to the thread web socket.
312 287 """
313 288
314 289 if not settings.get_bool('External', 'WebsocketsEnabled'):
315 290 return
316 291
317 292 thread_ids = list()
318 293 for thread in self.get_threads().all():
319 294 thread_ids.append(thread.id)
320 295
321 296 thread.notify_clients()
322 297
323 298 if recursive:
324 299 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
325 300 post_id = reply_number.group(1)
326 301
327 302 try:
328 303 ref_post = Post.objects.get(id=post_id)
329 304
330 305 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
331 306 # If post is in this thread, its thread was already notified.
332 307 # Otherwise, notify its thread separately.
333 308 ref_post.notify_clients(recursive=False)
334 309 except ObjectDoesNotExist:
335 310 pass
336 311
337 312 def build_url(self):
338 313 self.url = self.get_absolute_url()
339 314 self.save(update_fields=['url'])
340 315
341 316 def save(self, force_insert=False, force_update=False, using=None,
342 317 update_fields=None):
343 318 new_post = self.id is None
344 319
345 320 self.uid = str(uuid.uuid4())
346 321 if update_fields is not None and 'uid' not in update_fields:
347 322 update_fields += ['uid']
348 323
349 324 if not new_post:
350 325 for thread in self.get_threads().all():
351 326 thread.last_edit_time = self.last_edit_time
352 327
353 328 thread.save(update_fields=['last_edit_time', 'status'])
354 329
355 330 super().save(force_insert, force_update, using, update_fields)
356 331
357 332 if self.url is None:
358 333 self.build_url()
359 334
360 335 def get_text(self) -> str:
361 336 return self._text_rendered
362 337
363 338 def get_raw_text(self) -> str:
364 339 return self.text
365 340
366 341 def get_sync_text(self) -> str:
367 342 """
368 343 Returns text applicable for sync. It has absolute post reflinks.
369 344 """
370 345
371 346 replacements = dict()
372 347 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
373 348 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
374 349 replacements[post_id] = absolute_post_id
375 350
376 text = self.get_raw_text()
351 text = self.get_raw_text() or ''
377 352 for key in replacements:
378 353 text = text.replace('[post]{}[/post]'.format(key),
379 354 '[post]{}[/post]'.format(replacements[key]))
380 355 text = text.replace('\r\n', '\n').replace('\r', '\n')
381 356
382 357 return text
383 358
384 359 def get_absolute_id(self) -> str:
385 360 """
386 361 If the post has many threads, shows its main thread OP id in the post
387 362 ID.
388 363 """
389 364
390 365 if self.get_threads().count() > 1:
391 366 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
392 367 else:
393 368 return str(self.id)
394 369
395 370
396 371 def connect_threads(self, opening_posts):
397 372 for opening_post in opening_posts:
398 373 threads = opening_post.get_threads().all()
399 374 for thread in threads:
400 375 if thread.can_bump():
401 376 thread.update_bump_status()
402 377
403 378 thread.last_edit_time = self.last_edit_time
404 379 thread.save(update_fields=['last_edit_time', 'status'])
405 380 self.threads.add(opening_post.get_thread())
406 381
407 382 def get_tripcode(self):
408 383 if self.tripcode:
409 384 return Tripcode(self.tripcode)
410 385
411 386 def get_link_view(self):
412 387 """
413 388 Gets view of a reflink to the post.
414 389 """
415 390 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
416 391 self.id)
417 392 if self.is_opening():
418 393 result = '<b>{}</b>'.format(result)
419 394
420 395 return result
421 396
422 397 def is_hidden(self) -> bool:
423 398 return self.hidden
424 399
425 400 def set_hidden(self, hidden):
426 401 self.hidden = hidden
427 402
428 403
429 404 # SIGNALS (Maybe move to other module?)
430 405 @receiver(post_save, sender=Post)
431 406 def connect_replies(instance, **kwargs):
432 407 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
433 408 post_id = reply_number.group(1)
434 409
435 410 try:
436 411 referenced_post = Post.objects.get(id=post_id)
437 412
438 413 referenced_post.referenced_posts.add(instance)
439 414 referenced_post.last_edit_time = instance.pub_time
440 415 referenced_post.build_refmap()
441 416 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
442 417 except ObjectDoesNotExist:
443 418 pass
444 419
445 420
446 421 @receiver(post_save, sender=Post)
447 422 def connect_notifications(instance, **kwargs):
448 423 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
449 424 user_name = reply_number.group(1).lower()
450 425 Notification.objects.get_or_create(name=user_name, post=instance)
451 426
452 427
453 428 @receiver(pre_save, sender=Post)
454 429 def preparse_text(instance, **kwargs):
455 430 instance._text_rendered = get_parser().parse(instance.get_raw_text())
431
432
433 @receiver(pre_delete, sender=Post)
434 def delete_images(instance, **kwargs):
435 for image in instance.images.all():
436 image_refs_count = image.post_images.count()
437 if image_refs_count == 1:
438 image.delete()
439
440
441 @receiver(pre_delete, sender=Post)
442 def delete_attachments(instance, **kwargs):
443 for attachment in instance.attachments.all():
444 attachment_refs_count = attachment.attachment_posts.count()
445 if attachment_refs_count == 1:
446 attachment.delete()
447
448
449 @receiver(post_delete, sender=Post)
450 def update_thread_on_delete(instance, **kwargs):
451 thread = instance.get_thread()
452 thread.last_edit_time = timezone.now()
453 thread.save()
@@ -1,154 +1,160 b''
1 1 import logging
2 2
3 3 from datetime import datetime, timedelta, date
4 4 from datetime import time as dtime
5 5
6 6 from django.db import models, transaction
7 7 from django.utils import timezone
8 8
9 9 import boards
10 10
11 11 from boards.models.user import Ban
12 12 from boards.mdx_neboard import Parser
13 13 from boards.models import PostImage, Attachment
14 14 from boards import utils
15 15
16 16 __author__ = 'neko259'
17 17
18 18 IMAGE_TYPES = (
19 19 'jpeg',
20 20 'jpg',
21 21 'png',
22 22 'bmp',
23 23 'gif',
24 24 )
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27 NO_IP = '0.0.0.0'
28 28
29 29
30 30 class PostManager(models.Manager):
31 31 @transaction.atomic
32 32 def create_post(self, title: str, text: str, file=None, thread=None,
33 33 ip=NO_IP, tags: list=None, opening_posts: list=None,
34 34 tripcode='', monochrome=False, images=[]):
35 35 """
36 36 Creates new post
37 37 """
38 38
39 39 if thread is not None and thread.is_archived():
40 40 raise Exception('Cannot post into an archived thread')
41 41
42 42 if not utils.is_anonymous_mode():
43 43 is_banned = Ban.objects.filter(ip=ip).exists()
44 44 else:
45 45 is_banned = False
46 46
47 47 # TODO Raise specific exception and catch it in the views
48 48 if is_banned:
49 49 raise Exception("This user is banned")
50 50
51 51 if not tags:
52 52 tags = []
53 53 if not opening_posts:
54 54 opening_posts = []
55 55
56 56 posting_time = timezone.now()
57 57 new_thread = False
58 58 if not thread:
59 59 thread = boards.models.thread.Thread.objects.create(
60 60 bump_time=posting_time, last_edit_time=posting_time,
61 61 monochrome=monochrome)
62 62 list(map(thread.tags.add, tags))
63 63 boards.models.thread.Thread.objects.process_oldest_threads()
64 64 new_thread = True
65 65
66 66 pre_text = Parser().preparse(text)
67 67
68 68 post = self.create(title=title,
69 69 text=pre_text,
70 70 pub_time=posting_time,
71 71 poster_ip=ip,
72 72 thread=thread,
73 73 last_edit_time=posting_time,
74 74 tripcode=tripcode,
75 75 opening=new_thread)
76 76 post.threads.add(thread)
77 77
78 78 logger = logging.getLogger('boards.post.create')
79 79
80 80 logger.info('Created post [{}] with text [{}] by {}'.format(post,
81 81 post.get_text(),post.poster_ip))
82 82
83 # TODO Move this to other place
84 83 if file:
85 file_type = file.name.split('.')[-1].lower()
86 if file_type in IMAGE_TYPES:
87 post.images.add(PostImage.objects.create_with_hash(file))
88 else:
89 post.attachments.add(Attachment.objects.create_with_hash(file))
84 self._add_file_to_post(file, post)
90 85 for image in images:
91 86 post.images.add(image)
92 87
93 88 post.connect_threads(opening_posts)
94 89 post.set_global_id()
95 90
96 91 # Thread needs to be bumped only when the post is already created
97 92 if not new_thread:
98 93 thread.last_edit_time = posting_time
99 94 thread.bump()
100 95 thread.save()
101 96
102 97 return post
103 98
104 99 def delete_posts_by_ip(self, ip):
105 100 """
106 101 Deletes all posts of the author with same IP
107 102 """
108 103
109 104 posts = self.filter(poster_ip=ip)
110 105 for post in posts:
111 106 post.delete()
112 107
113 108 @utils.cached_result()
114 109 def get_posts_per_day(self) -> float:
115 110 """
116 111 Gets average count of posts per day for the last 7 days
117 112 """
118 113
119 114 day_end = date.today()
120 115 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
121 116
122 117 day_time_start = timezone.make_aware(datetime.combine(
123 118 day_start, dtime()), timezone.get_current_timezone())
124 119 day_time_end = timezone.make_aware(datetime.combine(
125 120 day_end, dtime()), timezone.get_current_timezone())
126 121
127 122 posts_per_period = float(self.filter(
128 123 pub_time__lte=day_time_end,
129 124 pub_time__gte=day_time_start).count())
130 125
131 126 ppd = posts_per_period / POSTS_PER_DAY_RANGE
132 127
133 128 return ppd
134 129
135 130 @transaction.atomic
136 131 def import_post(self, title: str, text: str, pub_time: str, global_id,
137 opening_post=None, tags=list()):
132 opening_post=None, tags=list(), files=list()):
138 133 is_opening = opening_post is None
139 134 if is_opening:
140 135 thread = boards.models.thread.Thread.objects.create(
141 136 bump_time=pub_time, last_edit_time=pub_time)
142 137 list(map(thread.tags.add, tags))
143 138 else:
144 139 thread = opening_post.get_thread()
145 140
146 141 post = self.create(title=title, text=text,
147 142 pub_time=pub_time,
148 143 poster_ip=NO_IP,
149 144 last_edit_time=pub_time,
150 145 global_id=global_id,
151 146 opening=is_opening,
152 147 thread=thread)
153 148
149 # TODO Add files
150 for file in files:
151 self._add_file_to_post(file, post)
152
154 153 post.threads.add(thread)
154
155 def _add_file_to_post(self, file, post):
156 file_type = file.name.split('.')[-1].lower()
157 if file_type in IMAGE_TYPES:
158 post.images.add(PostImage.objects.create_with_hash(file))
159 else:
160 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,228 +1,244 b''
1 1 import xml.etree.ElementTree as et
2 2
3 from boards.models.attachment.downloaders import download
3 4 from boards.utils import get_file_mimetype
4 5 from django.db import transaction
5 6 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
6 7
7 8 ENCODING_UNICODE = 'unicode'
8 9
9 10 TAG_MODEL = 'model'
10 11 TAG_REQUEST = 'request'
11 12 TAG_RESPONSE = 'response'
12 13 TAG_ID = 'id'
13 14 TAG_STATUS = 'status'
14 15 TAG_MODELS = 'models'
15 16 TAG_TITLE = 'title'
16 17 TAG_TEXT = 'text'
17 18 TAG_THREAD = 'thread'
18 19 TAG_PUB_TIME = 'pub-time'
19 20 TAG_SIGNATURES = 'signatures'
20 21 TAG_SIGNATURE = 'signature'
21 22 TAG_CONTENT = 'content'
22 23 TAG_ATTACHMENTS = 'attachments'
23 24 TAG_ATTACHMENT = 'attachment'
24 25 TAG_TAGS = 'tags'
25 26 TAG_TAG = 'tag'
26 27 TAG_ATTACHMENT_REFS = 'attachment-refs'
27 28 TAG_ATTACHMENT_REF = 'attachment-ref'
28 29
29 30 TYPE_GET = 'get'
30 31
31 32 ATTR_VERSION = 'version'
32 33 ATTR_TYPE = 'type'
33 34 ATTR_NAME = 'name'
34 35 ATTR_VALUE = 'value'
35 36 ATTR_MIMETYPE = 'mimetype'
36 37 ATTR_KEY = 'key'
37 38 ATTR_REF = 'ref'
38 39 ATTR_URL = 'url'
39 40
40 41 STATUS_SUCCESS = 'success'
41 42
42 43
44 class SyncException(Exception):
45 pass
46
47
43 48 class SyncManager:
44 49 @staticmethod
45 50 def generate_response_get(model_list: list):
46 51 response = et.Element(TAG_RESPONSE)
47 52
48 53 status = et.SubElement(response, TAG_STATUS)
49 54 status.text = STATUS_SUCCESS
50 55
51 56 models = et.SubElement(response, TAG_MODELS)
52 57
53 58 for post in model_list:
54 59 model = et.SubElement(models, TAG_MODEL)
55 60 model.set(ATTR_NAME, 'post')
56 61
57 62 content_tag = et.SubElement(model, TAG_CONTENT)
58 63
59 64 tag_id = et.SubElement(content_tag, TAG_ID)
60 65 post.global_id.to_xml_element(tag_id)
61 66
62 67 title = et.SubElement(content_tag, TAG_TITLE)
63 68 title.text = post.title
64 69
65 70 text = et.SubElement(content_tag, TAG_TEXT)
66 71 text.text = post.get_sync_text()
67 72
68 73 thread = post.get_thread()
69 74 if post.is_opening():
70 75 tag_tags = et.SubElement(content_tag, TAG_TAGS)
71 76 for tag in thread.get_tags():
72 77 tag_tag = et.SubElement(tag_tags, TAG_TAG)
73 78 tag_tag.text = tag.name
74 79 else:
75 80 tag_thread = et.SubElement(content_tag, TAG_THREAD)
76 81 thread_id = et.SubElement(tag_thread, TAG_ID)
77 82 thread.get_opening_post().global_id.to_xml_element(thread_id)
78 83
79 84 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
80 85 pub_time.text = str(post.get_pub_time_str())
81 86
82 87 images = post.images.all()
83 88 attachments = post.attachments.all()
84 89 if len(images) > 0 or len(attachments) > 0:
85 90 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
86 91 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
87 92
88 93 for image in images:
89 94 SyncManager._attachment_to_xml(
90 95 attachments_tag, attachment_refs, image.image.file,
91 96 image.hash, image.image.url)
92 97 for file in attachments:
93 98 SyncManager._attachment_to_xml(
94 99 attachments_tag, attachment_refs, file.file.file,
95 100 file.hash, file.file.url)
96 101
97 102 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
98 103 post_signatures = post.global_id.signature_set.all()
99 104 if post_signatures:
100 105 signatures = post_signatures
101 106 else:
102 107 key = KeyPair.objects.get(public_key=post.global_id.key)
103 108 signature = Signature(
104 109 key_type=key.key_type,
105 110 key=key.public_key,
106 111 signature=key.sign(et.tostring(content_tag, encoding=ENCODING_UNICODE)),
107 112 global_id=post.global_id,
108 113 )
109 114 signature.save()
110 115 signatures = [signature]
111 116 for signature in signatures:
112 117 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
113 118 signature_tag.set(ATTR_TYPE, signature.key_type)
114 119 signature_tag.set(ATTR_VALUE, signature.signature)
115 120 signature_tag.set(ATTR_KEY, signature.key)
116 121
117 122 return et.tostring(response, ENCODING_UNICODE)
118 123
119 124 @staticmethod
120 125 @transaction.atomic
121 def parse_response_get(response_xml):
126 def parse_response_get(response_xml, hostname):
122 127 tag_root = et.fromstring(response_xml)
123 128 tag_status = tag_root.find(TAG_STATUS)
124 129 if STATUS_SUCCESS == tag_status.text:
125 130 tag_models = tag_root.find(TAG_MODELS)
126 131 for tag_model in tag_models:
127 132 tag_content = tag_model.find(TAG_CONTENT)
128 133
129 134 signatures = SyncManager._verify_model(tag_content, tag_model)
130 135
131 136 tag_id = tag_content.find(TAG_ID)
132 137 global_id, exists = GlobalId.from_xml_element(tag_id)
133 138
134 139 if exists:
135 140 print('Post with same ID already exists')
136 141 else:
137 142 global_id.save()
138 143 for signature in signatures:
139 144 signature.global_id = global_id
140 145 signature.save()
141 146
142 title = tag_content.find(TAG_TITLE).text
143 text = tag_content.find(TAG_TEXT).text
147 title = tag_content.find(TAG_TITLE).text or ''
148 text = tag_content.find(TAG_TEXT).text or ''
144 149 pub_time = tag_content.find(TAG_PUB_TIME).text
145 150
146 151 thread = tag_content.find(TAG_THREAD)
147 152 tags = []
148 153 if thread:
149 154 thread_id = thread.find(TAG_ID)
150 155 op_global_id, exists = GlobalId.from_xml_element(thread_id)
151 156 if exists:
152 157 opening_post = Post.objects.get(global_id=op_global_id)
153 158 else:
154 raise Exception('Load the OP first')
159 raise SyncException('Load the OP first')
155 160 else:
156 161 opening_post = None
157 162 tag_tags = tag_content.find(TAG_TAGS)
158 163 for tag_tag in tag_tags:
159 164 tag, created = Tag.objects.get_or_create(
160 165 name=tag_tag.text)
161 166 tags.append(tag)
162 167
163 168 # TODO Check that the replied posts are already present
164 169 # before adding new ones
165 170
166 # TODO Get images
171 files = []
172 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
173 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
174 for attachment in tag_attachments:
175 tag_ref = tag_refs.find("{}[@ref='{}']".format(
176 TAG_ATTACHMENT_REF, attachment.text))
177 url = tag_ref.get(ATTR_URL)
178 attached_file = download(hostname + url)
179 if attached_file is None:
180 raise SyncException('File was not dowloaded')
181 files.append(attached_file)
182 # TODO Check hash
167 183
168 post = Post.objects.import_post(
184 Post.objects.import_post(
169 185 title=title, text=text, pub_time=pub_time,
170 186 opening_post=opening_post, tags=tags,
171 global_id=global_id)
187 global_id=global_id, files=files)
172 188 else:
173 189 # TODO Throw an exception?
174 190 pass
175 191
176 192 @staticmethod
177 193 def generate_response_pull():
178 194 response = et.Element(TAG_RESPONSE)
179 195
180 196 status = et.SubElement(response, TAG_STATUS)
181 197 status.text = STATUS_SUCCESS
182 198
183 199 models = et.SubElement(response, TAG_MODELS)
184 200
185 201 for post in Post.objects.all():
186 202 tag_id = et.SubElement(models, TAG_ID)
187 203 post.global_id.to_xml_element(tag_id)
188 204
189 205 return et.tostring(response, ENCODING_UNICODE)
190 206
191 207 @staticmethod
192 208 def _verify_model(tag_content, tag_model):
193 209 """
194 210 Verifies all signatures for a single model.
195 211 """
196 212
197 213 signatures = []
198 214
199 215 tag_signatures = tag_model.find(TAG_SIGNATURES)
200 216 for tag_signature in tag_signatures:
201 217 signature_type = tag_signature.get(ATTR_TYPE)
202 218 signature_value = tag_signature.get(ATTR_VALUE)
203 219 signature_key = tag_signature.get(ATTR_KEY)
204 220
205 221 signature = Signature(key_type=signature_type,
206 222 key=signature_key,
207 223 signature=signature_value)
208 224
209 225 content = et.tostring(tag_content, ENCODING_UNICODE)
210 226
211 227 if not KeyPair.objects.verify(
212 228 signature, content):
213 raise Exception('Invalid model signature for {}'.format(content))
229 raise SyncException('Invalid model signature for {}'.format(content))
214 230
215 231 signatures.append(signature)
216 232
217 233 return signatures
218 234
219 235 @staticmethod
220 236 def _attachment_to_xml(tag_attachments, tag_refs, file, hash, url):
221 237 mimetype = get_file_mimetype(file)
222 238 attachment = et.SubElement(tag_attachments, TAG_ATTACHMENT)
223 239 attachment.set(ATTR_MIMETYPE, mimetype)
224 240 attachment.text = hash
225 241
226 242 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
227 243 attachment_ref.set(ATTR_REF, hash)
228 244 attachment_ref.set(ATTR_URL, url)
@@ -1,102 +1,102 b''
1 1 from boards.models import KeyPair, Post, Tag
2 2 from boards.models.post.sync import SyncManager
3 3 from boards.tests.mocks import MockRequest
4 4 from boards.views.sync import response_get
5 5
6 6 __author__ = 'neko259'
7 7
8 8
9 9 from django.test import TestCase
10 10
11 11
12 12 class SyncTest(TestCase):
13 13 def test_get(self):
14 14 """
15 15 Forms a GET request of a post and checks the response.
16 16 """
17 17
18 18 key = KeyPair.objects.generate_key(primary=True)
19 19 tag = Tag.objects.create(name='tag1')
20 20 post = Post.objects.create_post(title='test_title',
21 21 text='test_text\rline two',
22 22 tags=[tag])
23 23
24 24 request = MockRequest()
25 25 request.body = (
26 26 '<request type="get" version="1.0">'
27 27 '<model name="post" version="1.0">'
28 28 '<id key="%s" local-id="%d" type="%s" />'
29 29 '</model>'
30 30 '</request>' % (post.global_id.key,
31 31 post.id,
32 32 post.global_id.key_type)
33 33 )
34 34
35 35 response = response_get(request).content.decode()
36 36 self.assertTrue(
37 37 '<status>success</status>'
38 38 '<models>'
39 39 '<model name="post">'
40 40 '<content>'
41 41 '<id key="%s" local-id="%d" type="%s" />'
42 42 '<title>%s</title>'
43 43 '<text>%s</text>'
44 44 '<tags><tag>%s</tag></tags>'
45 45 '<pub-time>%s</pub-time>'
46 46 '</content>' % (
47 47 post.global_id.key,
48 48 post.global_id.local_id,
49 49 post.global_id.key_type,
50 50 post.title,
51 51 post.get_sync_text(),
52 52 post.get_thread().get_tags().first().name,
53 53 post.get_pub_time_str(),
54 54 ) in response,
55 55 'Wrong response generated for the GET request.')
56 56
57 57 post.delete()
58 58 key.delete()
59 59
60 60 KeyPair.objects.generate_key(primary=True)
61 61
62 SyncManager.parse_response_get(response)
62 SyncManager.parse_response_get(response, None)
63 63 self.assertEqual(1, Post.objects.count(),
64 64 'Post was not created from XML response.')
65 65
66 66 parsed_post = Post.objects.first()
67 67 self.assertEqual('tag1',
68 68 parsed_post.get_thread().get_tags().first().name,
69 69 'Invalid tag was parsed.')
70 70
71 SyncManager.parse_response_get(response)
71 SyncManager.parse_response_get(response, None)
72 72 self.assertEqual(1, Post.objects.count(),
73 73 'The same post was imported twice.')
74 74
75 75 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
76 76 'Signature was not saved.')
77 77
78 78 post = parsed_post
79 79
80 80 # Trying to sync the same once more
81 81 response = response_get(request).content.decode()
82 82
83 83 self.assertTrue(
84 84 '<status>success</status>'
85 85 '<models>'
86 86 '<model name="post">'
87 87 '<content>'
88 88 '<id key="%s" local-id="%d" type="%s" />'
89 89 '<title>%s</title>'
90 90 '<text>%s</text>'
91 91 '<tags><tag>%s</tag></tags>'
92 92 '<pub-time>%s</pub-time>'
93 93 '</content>' % (
94 94 post.global_id.key,
95 95 post.global_id.local_id,
96 96 post.global_id.key_type,
97 97 post.title,
98 98 post.get_sync_text(),
99 99 post.get_thread().get_tags().first().name,
100 100 post.get_pub_time_str(),
101 101 ) in response,
102 102 'Wrong response generated for the GET request.')
@@ -1,56 +1,62 b''
1 1 import xml.etree.ElementTree as et
2 import xml.dom.minidom
3
2 4 from django.http import HttpResponse, Http404
3 5 from boards.models import GlobalId, Post
4 6 from boards.models.post.sync import SyncManager
5 7
6 8
7 9 def response_pull(request):
8 10 request_xml = request.body
9 11
10 12 if request_xml is None:
11 13 return HttpResponse(content='Use the API')
12 14
13 15 response_xml = SyncManager.generate_response_pull()
14 16
15 17 return HttpResponse(content=response_xml)
16 18
17 19
18 20 def response_get(request):
19 21 """
20 22 Processes a GET request with post ID list and returns the posts XML list.
21 23 Request should contain an 'xml' post attribute with the actual request XML.
22 24 """
23 25
24 26 request_xml = request.body
25 27
26 28 if request_xml is None:
27 29 return HttpResponse(content='Use the API')
28 30
29 31 posts = []
30 32
31 33 root_tag = et.fromstring(request_xml)
32 34 model_tag = root_tag[0]
33 35 for id_tag in model_tag:
34 36 global_id, exists = GlobalId.from_xml_element(id_tag)
35 37 if exists:
36 38 posts.append(Post.objects.get(global_id=global_id))
37 39
38 40 response_xml = SyncManager.generate_response_get(posts)
39 41
40 42 return HttpResponse(content=response_xml)
41 43
42 44
43 45 def get_post_sync_data(request, post_id):
44 46 try:
45 47 post = Post.objects.get(id=post_id)
46 48 except Post.DoesNotExist:
47 49 raise Http404()
48 50
49 content = 'Global ID: %s\n\nXML: %s' \
50 % (post.global_id, SyncManager.generate_response_get([post]))
51 xml_str = SyncManager.generate_response_get([post])
51 52
53 xml_repr = xml.dom.minidom.parseString(xml_str)
54 xml_repr = xml_repr.toprettyxml()
55
56 content = '=Global ID=\n%s\n\n=XML=\n%s' \
57 % (post.global_id, xml_repr)
52 58
53 59 return HttpResponse(
54 60 content_type='text/plain',
55 61 content=content,
56 62 ) No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now