##// END OF EJS Templates
Convert \r\n and \r to \n in the post text used in sync
neko259 -
r1504:ce9e0d38 decentral
parent child Browse files
Show More
@@ -1,454 +1,455 b''
1 import logging
1 import logging
2 import re
2 import re
3 import uuid
3 import uuid
4
4
5 from django.core.exceptions import ObjectDoesNotExist
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
7 from django.db import models
7 from django.db import models
8 from django.db.models import TextField, QuerySet
8 from django.db.models import TextField, QuerySet
9 from django.template.defaultfilters import truncatewords, striptags
9 from django.template.defaultfilters import truncatewords, striptags
10 from django.template.loader import render_to_string
10 from django.template.loader import render_to_string
11 from django.utils import timezone
11 from django.utils import timezone
12 from django.dispatch import receiver
12 from django.dispatch import receiver
13 from django.db.models.signals import pre_save, post_save
13 from django.db.models.signals import pre_save, post_save
14
14
15 from boards import settings
15 from boards import settings
16 from boards.abstracts.tripcode import Tripcode
16 from boards.abstracts.tripcode import Tripcode
17 from boards.mdx_neboard import get_parser
17 from boards.mdx_neboard import get_parser
18 from boards.models import PostImage, Attachment, KeyPair, GlobalId
18 from boards.models import PostImage, Attachment, KeyPair, GlobalId
19 from boards.models.base import Viewable
19 from boards.models.base import Viewable
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.post.manager import PostManager
21 from boards.models.post.manager import PostManager
22 from boards.models.user import Notification
22 from boards.models.user import Notification
23
23
24 CSS_CLS_HIDDEN_POST = 'hidden_post'
24 CSS_CLS_HIDDEN_POST = 'hidden_post'
25 CSS_CLS_DEAD_POST = 'dead_post'
25 CSS_CLS_DEAD_POST = 'dead_post'
26 CSS_CLS_ARCHIVE_POST = 'archive_post'
26 CSS_CLS_ARCHIVE_POST = 'archive_post'
27 CSS_CLS_POST = 'post'
27 CSS_CLS_POST = 'post'
28 CSS_CLS_MONOCHROME = 'monochrome'
28 CSS_CLS_MONOCHROME = 'monochrome'
29
29
30 TITLE_MAX_WORDS = 10
30 TITLE_MAX_WORDS = 10
31
31
32 APP_LABEL_BOARDS = 'boards'
32 APP_LABEL_BOARDS = 'boards'
33
33
34 BAN_REASON_AUTO = 'Auto'
34 BAN_REASON_AUTO = 'Auto'
35
35
36 IMAGE_THUMB_SIZE = (200, 150)
36 IMAGE_THUMB_SIZE = (200, 150)
37
37
38 TITLE_MAX_LENGTH = 200
38 TITLE_MAX_LENGTH = 200
39
39
40 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
40 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
41 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
41 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
42 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
42 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
43 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
43 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
44
44
45 PARAMETER_TRUNCATED = 'truncated'
45 PARAMETER_TRUNCATED = 'truncated'
46 PARAMETER_TAG = 'tag'
46 PARAMETER_TAG = 'tag'
47 PARAMETER_OFFSET = 'offset'
47 PARAMETER_OFFSET = 'offset'
48 PARAMETER_DIFF_TYPE = 'type'
48 PARAMETER_DIFF_TYPE = 'type'
49 PARAMETER_CSS_CLASS = 'css_class'
49 PARAMETER_CSS_CLASS = 'css_class'
50 PARAMETER_THREAD = 'thread'
50 PARAMETER_THREAD = 'thread'
51 PARAMETER_IS_OPENING = 'is_opening'
51 PARAMETER_IS_OPENING = 'is_opening'
52 PARAMETER_POST = 'post'
52 PARAMETER_POST = 'post'
53 PARAMETER_OP_ID = 'opening_post_id'
53 PARAMETER_OP_ID = 'opening_post_id'
54 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
54 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
55 PARAMETER_REPLY_LINK = 'reply_link'
55 PARAMETER_REPLY_LINK = 'reply_link'
56 PARAMETER_NEED_OP_DATA = 'need_op_data'
56 PARAMETER_NEED_OP_DATA = 'need_op_data'
57
57
58 POST_VIEW_PARAMS = (
58 POST_VIEW_PARAMS = (
59 'need_op_data',
59 'need_op_data',
60 'reply_link',
60 'reply_link',
61 'need_open_link',
61 'need_open_link',
62 'truncated',
62 'truncated',
63 'mode_tree',
63 'mode_tree',
64 'perms',
64 'perms',
65 'tree_depth',
65 'tree_depth',
66 )
66 )
67
67
68
68
69 class Post(models.Model, Viewable):
69 class Post(models.Model, Viewable):
70 """A post is a message."""
70 """A post is a message."""
71
71
72 objects = PostManager()
72 objects = PostManager()
73
73
74 class Meta:
74 class Meta:
75 app_label = APP_LABEL_BOARDS
75 app_label = APP_LABEL_BOARDS
76 ordering = ('id',)
76 ordering = ('id',)
77
77
78 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
78 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
79 pub_time = models.DateTimeField()
79 pub_time = models.DateTimeField()
80 text = TextField(blank=True, null=True)
80 text = TextField(blank=True, null=True)
81 _text_rendered = TextField(blank=True, null=True, editable=False)
81 _text_rendered = TextField(blank=True, null=True, editable=False)
82
82
83 images = models.ManyToManyField(PostImage, null=True, blank=True,
83 images = models.ManyToManyField(PostImage, null=True, blank=True,
84 related_name='post_images', db_index=True)
84 related_name='post_images', db_index=True)
85 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
85 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
86 related_name='attachment_posts')
86 related_name='attachment_posts')
87
87
88 poster_ip = models.GenericIPAddressField()
88 poster_ip = models.GenericIPAddressField()
89
89
90 # TODO This field can be removed cause UID is used for update now
90 # TODO This field can be removed cause UID is used for update now
91 last_edit_time = models.DateTimeField()
91 last_edit_time = models.DateTimeField()
92
92
93 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
93 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
94 null=True,
94 null=True,
95 blank=True, related_name='refposts',
95 blank=True, related_name='refposts',
96 db_index=True)
96 db_index=True)
97 refmap = models.TextField(null=True, blank=True)
97 refmap = models.TextField(null=True, blank=True)
98 threads = models.ManyToManyField('Thread', db_index=True,
98 threads = models.ManyToManyField('Thread', db_index=True,
99 related_name='multi_replies')
99 related_name='multi_replies')
100 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
100 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
101
101
102 url = models.TextField()
102 url = models.TextField()
103 uid = models.TextField(db_index=True)
103 uid = models.TextField(db_index=True)
104
104
105 # Global ID with author key. If the message was downloaded from another
105 # Global ID with author key. If the message was downloaded from another
106 # server, this indicates the server.
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
108
109 tripcode = models.CharField(max_length=50, blank=True, default='')
109 tripcode = models.CharField(max_length=50, blank=True, default='')
110 opening = models.BooleanField(db_index=True)
110 opening = models.BooleanField(db_index=True)
111 hidden = models.BooleanField(default=False)
111 hidden = models.BooleanField(default=False)
112
112
113 def __str__(self):
113 def __str__(self):
114 return 'P#{}/{}'.format(self.id, self.get_title())
114 return 'P#{}/{}'.format(self.id, self.get_title())
115
115
116 def get_referenced_posts(self):
116 def get_referenced_posts(self):
117 threads = self.get_threads().all()
117 threads = self.get_threads().all()
118 return self.referenced_posts.filter(threads__in=threads)\
118 return self.referenced_posts.filter(threads__in=threads)\
119 .order_by('pub_time').distinct().all()
119 .order_by('pub_time').distinct().all()
120
120
121 def get_title(self) -> str:
121 def get_title(self) -> str:
122 return self.title
122 return self.title
123
123
124 def get_title_or_text(self):
124 def get_title_or_text(self):
125 title = self.get_title()
125 title = self.get_title()
126 if not title:
126 if not title:
127 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
127 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
128
128
129 return title
129 return title
130
130
131 def build_refmap(self) -> None:
131 def build_refmap(self) -> None:
132 """
132 """
133 Builds a replies map string from replies list. This is a cache to stop
133 Builds a replies map string from replies list. This is a cache to stop
134 the server from recalculating the map on every post show.
134 the server from recalculating the map on every post show.
135 """
135 """
136
136
137 post_urls = [refpost.get_link_view()
137 post_urls = [refpost.get_link_view()
138 for refpost in self.referenced_posts.all()]
138 for refpost in self.referenced_posts.all()]
139
139
140 self.refmap = ', '.join(post_urls)
140 self.refmap = ', '.join(post_urls)
141
141
142 def is_referenced(self) -> bool:
142 def is_referenced(self) -> bool:
143 return self.refmap and len(self.refmap) > 0
143 return self.refmap and len(self.refmap) > 0
144
144
145 def is_opening(self) -> bool:
145 def is_opening(self) -> bool:
146 """
146 """
147 Checks if this is an opening post or just a reply.
147 Checks if this is an opening post or just a reply.
148 """
148 """
149
149
150 return self.opening
150 return self.opening
151
151
152 def get_absolute_url(self, thread=None):
152 def get_absolute_url(self, thread=None):
153 url = None
153 url = None
154
154
155 if thread is None:
155 if thread is None:
156 thread = self.get_thread()
156 thread = self.get_thread()
157
157
158 # Url is cached only for the "main" thread. When getting url
158 # Url is cached only for the "main" thread. When getting url
159 # for other threads, do it manually.
159 # for other threads, do it manually.
160 if self.url:
160 if self.url:
161 url = self.url
161 url = self.url
162
162
163 if url is None:
163 if url is None:
164 opening = self.is_opening()
164 opening = self.is_opening()
165 opening_id = self.id if opening else thread.get_opening_post_id()
165 opening_id = self.id if opening else thread.get_opening_post_id()
166 url = reverse('thread', kwargs={'post_id': opening_id})
166 url = reverse('thread', kwargs={'post_id': opening_id})
167 if not opening:
167 if not opening:
168 url += '#' + str(self.id)
168 url += '#' + str(self.id)
169
169
170 return url
170 return url
171
171
172 def get_thread(self):
172 def get_thread(self):
173 return self.thread
173 return self.thread
174
174
175 def get_thread_id(self):
175 def get_thread_id(self):
176 return self.thread_id
176 return self.thread_id
177 def get_threads(self) -> QuerySet:
177 def get_threads(self) -> QuerySet:
178 """
178 """
179 Gets post's thread.
179 Gets post's thread.
180 """
180 """
181
181
182 return self.threads
182 return self.threads
183
183
184 def get_view(self, *args, **kwargs) -> str:
184 def get_view(self, *args, **kwargs) -> str:
185 """
185 """
186 Renders post's HTML view. Some of the post params can be passed over
186 Renders post's HTML view. Some of the post params can be passed over
187 kwargs for the means of caching (if we view the thread, some params
187 kwargs for the means of caching (if we view the thread, some params
188 are same for every post and don't need to be computed over and over.
188 are same for every post and don't need to be computed over and over.
189 """
189 """
190
190
191 thread = self.get_thread()
191 thread = self.get_thread()
192
192
193 css_classes = [CSS_CLS_POST]
193 css_classes = [CSS_CLS_POST]
194 if thread.is_archived():
194 if thread.is_archived():
195 css_classes.append(CSS_CLS_ARCHIVE_POST)
195 css_classes.append(CSS_CLS_ARCHIVE_POST)
196 elif not thread.can_bump():
196 elif not thread.can_bump():
197 css_classes.append(CSS_CLS_DEAD_POST)
197 css_classes.append(CSS_CLS_DEAD_POST)
198 if self.is_hidden():
198 if self.is_hidden():
199 css_classes.append(CSS_CLS_HIDDEN_POST)
199 css_classes.append(CSS_CLS_HIDDEN_POST)
200 if thread.is_monochrome():
200 if thread.is_monochrome():
201 css_classes.append(CSS_CLS_MONOCHROME)
201 css_classes.append(CSS_CLS_MONOCHROME)
202
202
203 params = dict()
203 params = dict()
204 for param in POST_VIEW_PARAMS:
204 for param in POST_VIEW_PARAMS:
205 if param in kwargs:
205 if param in kwargs:
206 params[param] = kwargs[param]
206 params[param] = kwargs[param]
207
207
208 params.update({
208 params.update({
209 PARAMETER_POST: self,
209 PARAMETER_POST: self,
210 PARAMETER_IS_OPENING: self.is_opening(),
210 PARAMETER_IS_OPENING: self.is_opening(),
211 PARAMETER_THREAD: thread,
211 PARAMETER_THREAD: thread,
212 PARAMETER_CSS_CLASS: ' '.join(css_classes),
212 PARAMETER_CSS_CLASS: ' '.join(css_classes),
213 })
213 })
214
214
215 return render_to_string('boards/post.html', params)
215 return render_to_string('boards/post.html', params)
216
216
217 def get_search_view(self, *args, **kwargs):
217 def get_search_view(self, *args, **kwargs):
218 return self.get_view(need_op_data=True, *args, **kwargs)
218 return self.get_view(need_op_data=True, *args, **kwargs)
219
219
220 def get_first_image(self) -> PostImage:
220 def get_first_image(self) -> PostImage:
221 return self.images.earliest('id')
221 return self.images.earliest('id')
222
222
223 def delete(self, using=None):
223 def delete(self, using=None):
224 """
224 """
225 Deletes all post images and the post itself.
225 Deletes all post images and the post itself.
226 """
226 """
227
227
228 for image in self.images.all():
228 for image in self.images.all():
229 image_refs_count = image.post_images.count()
229 image_refs_count = image.post_images.count()
230 if image_refs_count == 1:
230 if image_refs_count == 1:
231 image.delete()
231 image.delete()
232
232
233 for attachment in self.attachments.all():
233 for attachment in self.attachments.all():
234 attachment_refs_count = attachment.attachment_posts.count()
234 attachment_refs_count = attachment.attachment_posts.count()
235 if attachment_refs_count == 1:
235 if attachment_refs_count == 1:
236 attachment.delete()
236 attachment.delete()
237
237
238 if self.global_id:
238 if self.global_id:
239 self.global_id.delete()
239 self.global_id.delete()
240
240
241 thread = self.get_thread()
241 thread = self.get_thread()
242 thread.last_edit_time = timezone.now()
242 thread.last_edit_time = timezone.now()
243 thread.save()
243 thread.save()
244
244
245 super(Post, self).delete(using)
245 super(Post, self).delete(using)
246
246
247 logging.getLogger('boards.post.delete').info(
247 logging.getLogger('boards.post.delete').info(
248 'Deleted post {}'.format(self))
248 'Deleted post {}'.format(self))
249
249
250 def set_global_id(self, key_pair=None):
250 def set_global_id(self, key_pair=None):
251 """
251 """
252 Sets global id based on the given key pair. If no key pair is given,
252 Sets global id based on the given key pair. If no key pair is given,
253 default one is used.
253 default one is used.
254 """
254 """
255
255
256 if key_pair:
256 if key_pair:
257 key = key_pair
257 key = key_pair
258 else:
258 else:
259 try:
259 try:
260 key = KeyPair.objects.get(primary=True)
260 key = KeyPair.objects.get(primary=True)
261 except KeyPair.DoesNotExist:
261 except KeyPair.DoesNotExist:
262 # Do not update the global id because there is no key defined
262 # Do not update the global id because there is no key defined
263 return
263 return
264 global_id = GlobalId(key_type=key.key_type,
264 global_id = GlobalId(key_type=key.key_type,
265 key=key.public_key,
265 key=key.public_key,
266 local_id=self.id)
266 local_id=self.id)
267 global_id.save()
267 global_id.save()
268
268
269 self.global_id = global_id
269 self.global_id = global_id
270
270
271 self.save(update_fields=['global_id'])
271 self.save(update_fields=['global_id'])
272
272
273 def get_pub_time_str(self):
273 def get_pub_time_str(self):
274 return str(self.pub_time)
274 return str(self.pub_time)
275
275
276 def get_replied_ids(self):
276 def get_replied_ids(self):
277 """
277 """
278 Gets ID list of the posts that this post replies.
278 Gets ID list of the posts that this post replies.
279 """
279 """
280
280
281 raw_text = self.get_raw_text()
281 raw_text = self.get_raw_text()
282
282
283 local_replied = REGEX_REPLY.findall(raw_text)
283 local_replied = REGEX_REPLY.findall(raw_text)
284 global_replied = []
284 global_replied = []
285 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
285 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
286 key_type = match[0]
286 key_type = match[0]
287 key = match[1]
287 key = match[1]
288 local_id = match[2]
288 local_id = match[2]
289
289
290 try:
290 try:
291 global_id = GlobalId.objects.get(key_type=key_type,
291 global_id = GlobalId.objects.get(key_type=key_type,
292 key=key, local_id=local_id)
292 key=key, local_id=local_id)
293 for post in Post.objects.filter(global_id=global_id).only('id'):
293 for post in Post.objects.filter(global_id=global_id).only('id'):
294 global_replied.append(post.id)
294 global_replied.append(post.id)
295 except GlobalId.DoesNotExist:
295 except GlobalId.DoesNotExist:
296 pass
296 pass
297 return local_replied + global_replied
297 return local_replied + global_replied
298
298
299 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
299 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
300 include_last_update=False) -> str:
300 include_last_update=False) -> str:
301 """
301 """
302 Gets post HTML or JSON data that can be rendered on a page or used by
302 Gets post HTML or JSON data that can be rendered on a page or used by
303 API.
303 API.
304 """
304 """
305
305
306 return get_exporter(format_type).export(self, request,
306 return get_exporter(format_type).export(self, request,
307 include_last_update)
307 include_last_update)
308
308
309 def notify_clients(self, recursive=True):
309 def notify_clients(self, recursive=True):
310 """
310 """
311 Sends post HTML data to the thread web socket.
311 Sends post HTML data to the thread web socket.
312 """
312 """
313
313
314 if not settings.get_bool('External', 'WebsocketsEnabled'):
314 if not settings.get_bool('External', 'WebsocketsEnabled'):
315 return
315 return
316
316
317 thread_ids = list()
317 thread_ids = list()
318 for thread in self.get_threads().all():
318 for thread in self.get_threads().all():
319 thread_ids.append(thread.id)
319 thread_ids.append(thread.id)
320
320
321 thread.notify_clients()
321 thread.notify_clients()
322
322
323 if recursive:
323 if recursive:
324 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
324 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
325 post_id = reply_number.group(1)
325 post_id = reply_number.group(1)
326
326
327 try:
327 try:
328 ref_post = Post.objects.get(id=post_id)
328 ref_post = Post.objects.get(id=post_id)
329
329
330 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
330 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
331 # If post is in this thread, its thread was already notified.
331 # If post is in this thread, its thread was already notified.
332 # Otherwise, notify its thread separately.
332 # Otherwise, notify its thread separately.
333 ref_post.notify_clients(recursive=False)
333 ref_post.notify_clients(recursive=False)
334 except ObjectDoesNotExist:
334 except ObjectDoesNotExist:
335 pass
335 pass
336
336
337 def build_url(self):
337 def build_url(self):
338 self.url = self.get_absolute_url()
338 self.url = self.get_absolute_url()
339 self.save(update_fields=['url'])
339 self.save(update_fields=['url'])
340
340
341 def save(self, force_insert=False, force_update=False, using=None,
341 def save(self, force_insert=False, force_update=False, using=None,
342 update_fields=None):
342 update_fields=None):
343 new_post = self.id is None
343 new_post = self.id is None
344
344
345 self.uid = str(uuid.uuid4())
345 self.uid = str(uuid.uuid4())
346 if update_fields is not None and 'uid' not in update_fields:
346 if update_fields is not None and 'uid' not in update_fields:
347 update_fields += ['uid']
347 update_fields += ['uid']
348
348
349 if not new_post:
349 if not new_post:
350 for thread in self.get_threads().all():
350 for thread in self.get_threads().all():
351 thread.last_edit_time = self.last_edit_time
351 thread.last_edit_time = self.last_edit_time
352
352
353 thread.save(update_fields=['last_edit_time', 'status'])
353 thread.save(update_fields=['last_edit_time', 'status'])
354
354
355 super().save(force_insert, force_update, using, update_fields)
355 super().save(force_insert, force_update, using, update_fields)
356
356
357 if self.url is None:
357 if self.url is None:
358 self.build_url()
358 self.build_url()
359
359
360 def get_text(self) -> str:
360 def get_text(self) -> str:
361 return self._text_rendered
361 return self._text_rendered
362
362
363 def get_raw_text(self) -> str:
363 def get_raw_text(self) -> str:
364 return self.text
364 return self.text
365
365
366 def get_sync_text(self) -> str:
366 def get_sync_text(self) -> str:
367 """
367 """
368 Returns text applicable for sync. It has absolute post reflinks.
368 Returns text applicable for sync. It has absolute post reflinks.
369 """
369 """
370
370
371 replacements = dict()
371 replacements = dict()
372 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
372 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
373 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
373 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
374 replacements[post_id] = absolute_post_id
374 replacements[post_id] = absolute_post_id
375
375
376 text = self.get_raw_text()
376 text = self.get_raw_text()
377 for key in replacements:
377 for key in replacements:
378 text = text.replace('[post]{}[/post]'.format(key),
378 text = text.replace('[post]{}[/post]'.format(key),
379 '[post]{}[/post]'.format(replacements[key]))
379 '[post]{}[/post]'.format(replacements[key]))
380 text = text.replace('\r\n', '\n').replace('\r', '\n')
380
381
381 return text
382 return text
382
383
383 def get_absolute_id(self) -> str:
384 def get_absolute_id(self) -> str:
384 """
385 """
385 If the post has many threads, shows its main thread OP id in the post
386 If the post has many threads, shows its main thread OP id in the post
386 ID.
387 ID.
387 """
388 """
388
389
389 if self.get_threads().count() > 1:
390 if self.get_threads().count() > 1:
390 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
391 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
391 else:
392 else:
392 return str(self.id)
393 return str(self.id)
393
394
394
395
395 def connect_threads(self, opening_posts):
396 def connect_threads(self, opening_posts):
396 for opening_post in opening_posts:
397 for opening_post in opening_posts:
397 threads = opening_post.get_threads().all()
398 threads = opening_post.get_threads().all()
398 for thread in threads:
399 for thread in threads:
399 if thread.can_bump():
400 if thread.can_bump():
400 thread.update_bump_status()
401 thread.update_bump_status()
401
402
402 thread.last_edit_time = self.last_edit_time
403 thread.last_edit_time = self.last_edit_time
403 thread.save(update_fields=['last_edit_time', 'status'])
404 thread.save(update_fields=['last_edit_time', 'status'])
404 self.threads.add(opening_post.get_thread())
405 self.threads.add(opening_post.get_thread())
405
406
406 def get_tripcode(self):
407 def get_tripcode(self):
407 if self.tripcode:
408 if self.tripcode:
408 return Tripcode(self.tripcode)
409 return Tripcode(self.tripcode)
409
410
410 def get_link_view(self):
411 def get_link_view(self):
411 """
412 """
412 Gets view of a reflink to the post.
413 Gets view of a reflink to the post.
413 """
414 """
414 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
415 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
415 self.id)
416 self.id)
416 if self.is_opening():
417 if self.is_opening():
417 result = '<b>{}</b>'.format(result)
418 result = '<b>{}</b>'.format(result)
418
419
419 return result
420 return result
420
421
421 def is_hidden(self) -> bool:
422 def is_hidden(self) -> bool:
422 return self.hidden
423 return self.hidden
423
424
424 def set_hidden(self, hidden):
425 def set_hidden(self, hidden):
425 self.hidden = hidden
426 self.hidden = hidden
426
427
427
428
428 # SIGNALS (Maybe move to other module?)
429 # SIGNALS (Maybe move to other module?)
429 @receiver(post_save, sender=Post)
430 @receiver(post_save, sender=Post)
430 def connect_replies(instance, **kwargs):
431 def connect_replies(instance, **kwargs):
431 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
432 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
432 post_id = reply_number.group(1)
433 post_id = reply_number.group(1)
433
434
434 try:
435 try:
435 referenced_post = Post.objects.get(id=post_id)
436 referenced_post = Post.objects.get(id=post_id)
436
437
437 referenced_post.referenced_posts.add(instance)
438 referenced_post.referenced_posts.add(instance)
438 referenced_post.last_edit_time = instance.pub_time
439 referenced_post.last_edit_time = instance.pub_time
439 referenced_post.build_refmap()
440 referenced_post.build_refmap()
440 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
441 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
441 except ObjectDoesNotExist:
442 except ObjectDoesNotExist:
442 pass
443 pass
443
444
444
445
445 @receiver(post_save, sender=Post)
446 @receiver(post_save, sender=Post)
446 def connect_notifications(instance, **kwargs):
447 def connect_notifications(instance, **kwargs):
447 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
448 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
448 user_name = reply_number.group(1).lower()
449 user_name = reply_number.group(1).lower()
449 Notification.objects.get_or_create(name=user_name, post=instance)
450 Notification.objects.get_or_create(name=user_name, post=instance)
450
451
451
452
452 @receiver(pre_save, sender=Post)
453 @receiver(pre_save, sender=Post)
453 def preparse_text(instance, **kwargs):
454 def preparse_text(instance, **kwargs):
454 instance._text_rendered = get_parser().parse(instance.get_raw_text())
455 instance._text_rendered = get_parser().parse(instance.get_raw_text())
@@ -1,201 +1,201 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2 from django.db import transaction
2 from django.db import transaction
3 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
3 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
4
4
5 ENCODING_UNICODE = 'unicode'
5 ENCODING_UNICODE = 'unicode'
6
6
7 TAG_MODEL = 'model'
7 TAG_MODEL = 'model'
8 TAG_REQUEST = 'request'
8 TAG_REQUEST = 'request'
9 TAG_RESPONSE = 'response'
9 TAG_RESPONSE = 'response'
10 TAG_ID = 'id'
10 TAG_ID = 'id'
11 TAG_STATUS = 'status'
11 TAG_STATUS = 'status'
12 TAG_MODELS = 'models'
12 TAG_MODELS = 'models'
13 TAG_TITLE = 'title'
13 TAG_TITLE = 'title'
14 TAG_TEXT = 'text'
14 TAG_TEXT = 'text'
15 TAG_THREAD = 'thread'
15 TAG_THREAD = 'thread'
16 TAG_PUB_TIME = 'pub-time'
16 TAG_PUB_TIME = 'pub-time'
17 TAG_SIGNATURES = 'signatures'
17 TAG_SIGNATURES = 'signatures'
18 TAG_SIGNATURE = 'signature'
18 TAG_SIGNATURE = 'signature'
19 TAG_CONTENT = 'content'
19 TAG_CONTENT = 'content'
20 TAG_ATTACHMENTS = 'attachments'
20 TAG_ATTACHMENTS = 'attachments'
21 TAG_ATTACHMENT = 'attachment'
21 TAG_ATTACHMENT = 'attachment'
22 TAG_TAGS = 'tags'
22 TAG_TAGS = 'tags'
23 TAG_TAG = 'tag'
23 TAG_TAG = 'tag'
24
24
25 TYPE_GET = 'get'
25 TYPE_GET = 'get'
26
26
27 ATTR_VERSION = 'version'
27 ATTR_VERSION = 'version'
28 ATTR_TYPE = 'type'
28 ATTR_TYPE = 'type'
29 ATTR_NAME = 'name'
29 ATTR_NAME = 'name'
30 ATTR_VALUE = 'value'
30 ATTR_VALUE = 'value'
31 ATTR_MIMETYPE = 'mimetype'
31 ATTR_MIMETYPE = 'mimetype'
32 ATTR_KEY = 'key'
32 ATTR_KEY = 'key'
33
33
34 STATUS_SUCCESS = 'success'
34 STATUS_SUCCESS = 'success'
35
35
36
36
37 class SyncManager:
37 class SyncManager:
38 @staticmethod
38 @staticmethod
39 def generate_response_get(model_list: list):
39 def generate_response_get(model_list: list):
40 response = et.Element(TAG_RESPONSE)
40 response = et.Element(TAG_RESPONSE)
41
41
42 status = et.SubElement(response, TAG_STATUS)
42 status = et.SubElement(response, TAG_STATUS)
43 status.text = STATUS_SUCCESS
43 status.text = STATUS_SUCCESS
44
44
45 models = et.SubElement(response, TAG_MODELS)
45 models = et.SubElement(response, TAG_MODELS)
46
46
47 for post in model_list:
47 for post in model_list:
48 model = et.SubElement(models, TAG_MODEL)
48 model = et.SubElement(models, TAG_MODEL)
49 model.set(ATTR_NAME, 'post')
49 model.set(ATTR_NAME, 'post')
50
50
51 content_tag = et.SubElement(model, TAG_CONTENT)
51 content_tag = et.SubElement(model, TAG_CONTENT)
52
52
53 tag_id = et.SubElement(content_tag, TAG_ID)
53 tag_id = et.SubElement(content_tag, TAG_ID)
54 post.global_id.to_xml_element(tag_id)
54 post.global_id.to_xml_element(tag_id)
55
55
56 title = et.SubElement(content_tag, TAG_TITLE)
56 title = et.SubElement(content_tag, TAG_TITLE)
57 title.text = post.title
57 title.text = post.title
58
58
59 text = et.SubElement(content_tag, TAG_TEXT)
59 text = et.SubElement(content_tag, TAG_TEXT)
60 text.text = post.get_sync_text()
60 text.text = post.get_sync_text()
61
61
62 thread = post.get_thread()
62 thread = post.get_thread()
63 if post.is_opening():
63 if post.is_opening():
64 tag_tags = et.SubElement(content_tag, TAG_TAGS)
64 tag_tags = et.SubElement(content_tag, TAG_TAGS)
65 for tag in thread.get_tags():
65 for tag in thread.get_tags():
66 tag_tag = et.SubElement(tag_tags, TAG_TAG)
66 tag_tag = et.SubElement(tag_tags, TAG_TAG)
67 tag_tag.text = tag.name
67 tag_tag.text = tag.name
68 else:
68 else:
69 tag_thread = et.SubElement(content_tag, TAG_THREAD)
69 tag_thread = et.SubElement(content_tag, TAG_THREAD)
70 thread_id = et.SubElement(tag_thread, TAG_ID)
70 thread_id = et.SubElement(tag_thread, TAG_ID)
71 thread.get_opening_post().global_id.to_xml_element(thread_id)
71 thread.get_opening_post().global_id.to_xml_element(thread_id)
72
72
73 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
73 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
74 pub_time.text = str(post.get_pub_time_str())
74 pub_time.text = str(post.get_pub_time_str())
75
75
76 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
76 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
77 post_signatures = post.global_id.signature_set.all()
77 post_signatures = post.global_id.signature_set.all()
78 if post_signatures:
78 if post_signatures:
79 signatures = post_signatures
79 signatures = post_signatures
80 # TODO Adding signature to a post is not yet added. For now this
80 # TODO Adding signature to a post is not yet added. For now this
81 # block is useless
81 # block is useless
82 else:
82 else:
83 # TODO Maybe the signature can be computed only once after
83 # TODO Maybe the signature can be computed only once after
84 # the post is added? Need to add some on_save signal queue
84 # the post is added? Need to add some on_save signal queue
85 # and add this there.
85 # and add this there.
86 key = KeyPair.objects.get(public_key=post.global_id.key)
86 key = KeyPair.objects.get(public_key=post.global_id.key)
87 signature = Signature(
87 signature = Signature(
88 key_type=key.key_type,
88 key_type=key.key_type,
89 key=key.public_key,
89 key=key.public_key,
90 signature=key.sign(et.tostring(content_tag, ENCODING_UNICODE)),
90 signature=key.sign(et.tostring(content_tag, encoding=ENCODING_UNICODE)),
91 global_id=post.global_id,
91 global_id=post.global_id,
92 )
92 )
93 signature.save()
93 signature.save()
94 signatures = [signature]
94 signatures = [signature]
95 for signature in signatures:
95 for signature in signatures:
96 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
96 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
97 signature_tag.set(ATTR_TYPE, signature.key_type)
97 signature_tag.set(ATTR_TYPE, signature.key_type)
98 signature_tag.set(ATTR_VALUE, signature.signature)
98 signature_tag.set(ATTR_VALUE, signature.signature)
99 signature_tag.set(ATTR_KEY, signature.key)
99 signature_tag.set(ATTR_KEY, signature.key)
100
100
101 return et.tostring(response, ENCODING_UNICODE)
101 return et.tostring(response, ENCODING_UNICODE)
102
102
103 @staticmethod
103 @staticmethod
104 @transaction.atomic
104 @transaction.atomic
105 def parse_response_get(response_xml):
105 def parse_response_get(response_xml):
106 tag_root = et.fromstring(response_xml)
106 tag_root = et.fromstring(response_xml)
107 tag_status = tag_root.find(TAG_STATUS)
107 tag_status = tag_root.find(TAG_STATUS)
108 if STATUS_SUCCESS == tag_status.text:
108 if STATUS_SUCCESS == tag_status.text:
109 tag_models = tag_root.find(TAG_MODELS)
109 tag_models = tag_root.find(TAG_MODELS)
110 for tag_model in tag_models:
110 for tag_model in tag_models:
111 tag_content = tag_model.find(TAG_CONTENT)
111 tag_content = tag_model.find(TAG_CONTENT)
112
112
113 signatures = SyncManager._verify_model(tag_content, tag_model)
113 signatures = SyncManager._verify_model(tag_content, tag_model)
114
114
115 tag_id = tag_content.find(TAG_ID)
115 tag_id = tag_content.find(TAG_ID)
116 global_id, exists = GlobalId.from_xml_element(tag_id)
116 global_id, exists = GlobalId.from_xml_element(tag_id)
117
117
118 if exists:
118 if exists:
119 print('Post with same ID already exists')
119 print('Post with same ID already exists')
120 else:
120 else:
121 global_id.save()
121 global_id.save()
122 for signature in signatures:
122 for signature in signatures:
123 signature.global_id = global_id
123 signature.global_id = global_id
124 signature.save()
124 signature.save()
125
125
126 title = tag_content.find(TAG_TITLE).text
126 title = tag_content.find(TAG_TITLE).text
127 text = tag_content.find(TAG_TEXT).text
127 text = tag_content.find(TAG_TEXT).text
128 pub_time = tag_content.find(TAG_PUB_TIME).text
128 pub_time = tag_content.find(TAG_PUB_TIME).text
129
129
130 thread = tag_content.find(TAG_THREAD)
130 thread = tag_content.find(TAG_THREAD)
131 tags = []
131 tags = []
132 if thread:
132 if thread:
133 thread_id = thread.find(TAG_ID)
133 thread_id = thread.find(TAG_ID)
134 op_global_id, exists = GlobalId.from_xml_element(thread_id)
134 op_global_id, exists = GlobalId.from_xml_element(thread_id)
135 if exists:
135 if exists:
136 opening_post = Post.objects.get(global_id=op_global_id)
136 opening_post = Post.objects.get(global_id=op_global_id)
137 else:
137 else:
138 raise Exception('Load the OP first')
138 raise Exception('Load the OP first')
139 else:
139 else:
140 opening_post = None
140 opening_post = None
141 tag_tags = tag_content.find(TAG_TAGS)
141 tag_tags = tag_content.find(TAG_TAGS)
142 for tag_tag in tag_tags:
142 for tag_tag in tag_tags:
143 tag, created = Tag.objects.get_or_create(
143 tag, created = Tag.objects.get_or_create(
144 name=tag_tag.text)
144 name=tag_tag.text)
145 tags.append(tag)
145 tags.append(tag)
146
146
147 # TODO Check that the replied posts are already present
147 # TODO Check that the replied posts are already present
148 # before adding new ones
148 # before adding new ones
149
149
150 # TODO Get images
150 # TODO Get images
151
151
152 post = Post.objects.import_post(
152 post = Post.objects.import_post(
153 title=title, text=text, pub_time=pub_time,
153 title=title, text=text, pub_time=pub_time,
154 opening_post=opening_post, tags=tags,
154 opening_post=opening_post, tags=tags,
155 global_id=global_id)
155 global_id=global_id)
156 else:
156 else:
157 # TODO Throw an exception?
157 # TODO Throw an exception?
158 pass
158 pass
159
159
160 @staticmethod
160 @staticmethod
161 def generate_response_pull():
161 def generate_response_pull():
162 response = et.Element(TAG_RESPONSE)
162 response = et.Element(TAG_RESPONSE)
163
163
164 status = et.SubElement(response, TAG_STATUS)
164 status = et.SubElement(response, TAG_STATUS)
165 status.text = STATUS_SUCCESS
165 status.text = STATUS_SUCCESS
166
166
167 models = et.SubElement(response, TAG_MODELS)
167 models = et.SubElement(response, TAG_MODELS)
168
168
169 for post in Post.objects.all():
169 for post in Post.objects.all():
170 tag_id = et.SubElement(models, TAG_ID)
170 tag_id = et.SubElement(models, TAG_ID)
171 post.global_id.to_xml_element(tag_id)
171 post.global_id.to_xml_element(tag_id)
172
172
173 return et.tostring(response, ENCODING_UNICODE)
173 return et.tostring(response, ENCODING_UNICODE)
174
174
175 @staticmethod
175 @staticmethod
176 def _verify_model(tag_content, tag_model):
176 def _verify_model(tag_content, tag_model):
177 """
177 """
178 Verifies all signatures for a single model.
178 Verifies all signatures for a single model.
179 """
179 """
180
180
181 signatures = []
181 signatures = []
182
182
183 tag_signatures = tag_model.find(TAG_SIGNATURES)
183 tag_signatures = tag_model.find(TAG_SIGNATURES)
184 for tag_signature in tag_signatures:
184 for tag_signature in tag_signatures:
185 signature_type = tag_signature.get(ATTR_TYPE)
185 signature_type = tag_signature.get(ATTR_TYPE)
186 signature_value = tag_signature.get(ATTR_VALUE)
186 signature_value = tag_signature.get(ATTR_VALUE)
187 signature_key = tag_signature.get(ATTR_KEY)
187 signature_key = tag_signature.get(ATTR_KEY)
188
188
189 signature = Signature(key_type=signature_type,
189 signature = Signature(key_type=signature_type,
190 key=signature_key,
190 key=signature_key,
191 signature=signature_value)
191 signature=signature_value)
192
192
193 content = et.tostring(tag_content, ENCODING_UNICODE)
193 content = et.tostring(tag_content, ENCODING_UNICODE)
194
194
195 if not KeyPair.objects.verify(
195 if not KeyPair.objects.verify(
196 signature, content):
196 signature, content):
197 raise Exception('Invalid model signature for {}'.format(content))
197 raise Exception('Invalid model signature for {}'.format(content))
198
198
199 signatures.append(signature)
199 signatures.append(signature)
200
200
201 return signatures
201 return signatures
@@ -1,101 +1,102 b''
1 from boards.models import KeyPair, Post, Tag
1 from boards.models import KeyPair, Post, Tag
2 from boards.models.post.sync import SyncManager
2 from boards.models.post.sync import SyncManager
3 from boards.tests.mocks import MockRequest
3 from boards.tests.mocks import MockRequest
4 from boards.views.sync import response_get
4 from boards.views.sync import response_get
5
5
6 __author__ = 'neko259'
6 __author__ = 'neko259'
7
7
8
8
9 from django.test import TestCase
9 from django.test import TestCase
10
10
11
11
12 class SyncTest(TestCase):
12 class SyncTest(TestCase):
13 def test_get(self):
13 def test_get(self):
14 """
14 """
15 Forms a GET request of a post and checks the response.
15 Forms a GET request of a post and checks the response.
16 """
16 """
17
17
18 key = KeyPair.objects.generate_key(primary=True)
18 key = KeyPair.objects.generate_key(primary=True)
19 tag = Tag.objects.create(name='tag1')
19 tag = Tag.objects.create(name='tag1')
20 post = Post.objects.create_post(title='test_title', text='test_text',
20 post = Post.objects.create_post(title='test_title',
21 text='test_text\rline two',
21 tags=[tag])
22 tags=[tag])
22
23
23 request = MockRequest()
24 request = MockRequest()
24 request.body = (
25 request.body = (
25 '<request type="get" version="1.0">'
26 '<request type="get" version="1.0">'
26 '<model name="post" version="1.0">'
27 '<model name="post" version="1.0">'
27 '<id key="%s" local-id="%d" type="%s" />'
28 '<id key="%s" local-id="%d" type="%s" />'
28 '</model>'
29 '</model>'
29 '</request>' % (post.global_id.key,
30 '</request>' % (post.global_id.key,
30 post.id,
31 post.id,
31 post.global_id.key_type)
32 post.global_id.key_type)
32 )
33 )
33
34
34 response = response_get(request).content.decode()
35 response = response_get(request).content.decode()
35 self.assertTrue(
36 self.assertTrue(
36 '<status>success</status>'
37 '<status>success</status>'
37 '<models>'
38 '<models>'
38 '<model name="post">'
39 '<model name="post">'
39 '<content>'
40 '<content>'
40 '<id key="%s" local-id="%d" type="%s" />'
41 '<id key="%s" local-id="%d" type="%s" />'
41 '<title>%s</title>'
42 '<title>%s</title>'
42 '<text>%s</text>'
43 '<text>%s</text>'
43 '<tags><tag>%s</tag></tags>'
44 '<tags><tag>%s</tag></tags>'
44 '<pub-time>%s</pub-time>'
45 '<pub-time>%s</pub-time>'
45 '</content>' % (
46 '</content>' % (
46 post.global_id.key,
47 post.global_id.key,
47 post.global_id.local_id,
48 post.global_id.local_id,
48 post.global_id.key_type,
49 post.global_id.key_type,
49 post.title,
50 post.title,
50 post.get_raw_text(),
51 post.get_sync_text(),
51 post.get_thread().get_tags().first().name,
52 post.get_thread().get_tags().first().name,
52 post.get_pub_time_str(),
53 post.get_pub_time_str(),
53 ) in response,
54 ) in response,
54 'Wrong response generated for the GET request.')
55 'Wrong response generated for the GET request.')
55
56
56 post.delete()
57 post.delete()
57 key.delete()
58 key.delete()
58
59
59 KeyPair.objects.generate_key(primary=True)
60 KeyPair.objects.generate_key(primary=True)
60
61
61 SyncManager.parse_response_get(response)
62 SyncManager.parse_response_get(response)
62 self.assertEqual(1, Post.objects.count(),
63 self.assertEqual(1, Post.objects.count(),
63 'Post was not created from XML response.')
64 'Post was not created from XML response.')
64
65
65 parsed_post = Post.objects.first()
66 parsed_post = Post.objects.first()
66 self.assertEqual('tag1',
67 self.assertEqual('tag1',
67 parsed_post.get_thread().get_tags().first().name,
68 parsed_post.get_thread().get_tags().first().name,
68 'Invalid tag was parsed.')
69 'Invalid tag was parsed.')
69
70
70 SyncManager.parse_response_get(response)
71 SyncManager.parse_response_get(response)
71 self.assertEqual(1, Post.objects.count(),
72 self.assertEqual(1, Post.objects.count(),
72 'The same post was imported twice.')
73 'The same post was imported twice.')
73
74
74 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
75 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
75 'Signature was not saved.')
76 'Signature was not saved.')
76
77
77 post = parsed_post
78 post = parsed_post
78
79
79 # Trying to sync the same once more
80 # Trying to sync the same once more
80 response = response_get(request).content.decode()
81 response = response_get(request).content.decode()
81
82
82 self.assertTrue(
83 self.assertTrue(
83 '<status>success</status>'
84 '<status>success</status>'
84 '<models>'
85 '<models>'
85 '<model name="post">'
86 '<model name="post">'
86 '<content>'
87 '<content>'
87 '<id key="%s" local-id="%d" type="%s" />'
88 '<id key="%s" local-id="%d" type="%s" />'
88 '<title>%s</title>'
89 '<title>%s</title>'
89 '<text>%s</text>'
90 '<text>%s</text>'
90 '<tags><tag>%s</tag></tags>'
91 '<tags><tag>%s</tag></tags>'
91 '<pub-time>%s</pub-time>'
92 '<pub-time>%s</pub-time>'
92 '</content>' % (
93 '</content>' % (
93 post.global_id.key,
94 post.global_id.key,
94 post.global_id.local_id,
95 post.global_id.local_id,
95 post.global_id.key_type,
96 post.global_id.key_type,
96 post.title,
97 post.title,
97 post.get_raw_text(),
98 post.get_sync_text(),
98 post.get_thread().get_tags().first().name,
99 post.get_thread().get_tags().first().name,
99 post.get_pub_time_str(),
100 post.get_pub_time_str(),
100 ) in response,
101 ) in response,
101 'Wrong response generated for the GET request.')
102 'Wrong response generated for the GET request.')
General Comments 0
You need to be logged in to leave comments. Login now