##// END OF EJS Templates
Use global thread ID instead of local one.
neko259 -
r936:ff78113e decentral
parent child Browse files
Show More
@@ -1,621 +1,622 b''
1 from datetime import datetime, timedelta, date
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
2 from datetime import time as dtime
3 import logging
3 import logging
4 import re
4 import re
5 import xml.etree.ElementTree as et
5 import xml.etree.ElementTree as et
6
6
7 from adjacent import Client
7 from adjacent import Client
8 from django.core.cache import cache
8 from django.core.cache import cache
9 from django.core.urlresolvers import reverse
9 from django.core.urlresolvers import reverse
10 from django.db import models, transaction
10 from django.db import models, transaction
11 from django.db.models import TextField
11 from django.db.models import TextField
12 from django.template.loader import render_to_string
12 from django.template.loader import render_to_string
13 from django.utils import timezone
13 from django.utils import timezone
14
14
15 from boards.models import PostImage, KeyPair, GlobalId, Signature
15 from boards.models import PostImage, KeyPair, GlobalId, Signature
16 from boards import settings
16 from boards import settings
17 from boards.mdx_neboard import bbcode_extended
17 from boards.mdx_neboard import bbcode_extended
18 from boards.models import PostImage
18 from boards.models import PostImage
19 from boards.models.base import Viewable
19 from boards.models.base import Viewable
20 from boards.models.thread import Thread
20 from boards.models.thread import Thread
21 from boards import utils
21 from boards import utils
22 from boards.utils import datetime_to_epoch
22 from boards.utils import datetime_to_epoch
23
23
24 ENCODING_UNICODE = 'unicode'
24 ENCODING_UNICODE = 'unicode'
25
25
26 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
27 WS_NOTIFICATION_TYPE = 'notification_type'
27 WS_NOTIFICATION_TYPE = 'notification_type'
28
28
29 WS_CHANNEL_THREAD = "thread:"
29 WS_CHANNEL_THREAD = "thread:"
30
30
31 APP_LABEL_BOARDS = 'boards'
31 APP_LABEL_BOARDS = 'boards'
32
32
33 CACHE_KEY_PPD = 'ppd'
33 CACHE_KEY_PPD = 'ppd'
34 CACHE_KEY_POST_URL = 'post_url'
34 CACHE_KEY_POST_URL = 'post_url'
35
35
36 POSTS_PER_DAY_RANGE = 7
36 POSTS_PER_DAY_RANGE = 7
37
37
38 BAN_REASON_AUTO = 'Auto'
38 BAN_REASON_AUTO = 'Auto'
39
39
40 IMAGE_THUMB_SIZE = (200, 150)
40 IMAGE_THUMB_SIZE = (200, 150)
41
41
42 TITLE_MAX_LENGTH = 200
42 TITLE_MAX_LENGTH = 200
43
43
44 # TODO This should be removed
44 # TODO This should be removed
45 NO_IP = '0.0.0.0'
45 NO_IP = '0.0.0.0'
46
46
47 # TODO Real user agent should be saved instead of this
47 # TODO Real user agent should be saved instead of this
48 UNKNOWN_UA = ''
48 UNKNOWN_UA = ''
49
49
50 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
50 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
51 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
51 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
52
52
53 TAG_MODEL = 'model'
53 TAG_MODEL = 'model'
54 TAG_REQUEST = 'request'
54 TAG_REQUEST = 'request'
55 TAG_RESPONSE = 'response'
55 TAG_RESPONSE = 'response'
56 TAG_ID = 'id'
56 TAG_ID = 'id'
57 TAG_STATUS = 'status'
57 TAG_STATUS = 'status'
58 TAG_MODELS = 'models'
58 TAG_MODELS = 'models'
59 TAG_TITLE = 'title'
59 TAG_TITLE = 'title'
60 TAG_TEXT = 'text'
60 TAG_TEXT = 'text'
61 TAG_THREAD = 'thread'
61 TAG_THREAD = 'thread'
62 TAG_PUB_TIME = 'pub-time'
62 TAG_PUB_TIME = 'pub-time'
63 TAG_SIGNATURES = 'signatures'
63 TAG_SIGNATURES = 'signatures'
64 TAG_SIGNATURE = 'signature'
64 TAG_SIGNATURE = 'signature'
65 TAG_CONTENT = 'content'
65 TAG_CONTENT = 'content'
66 TAG_ATTACHMENTS = 'attachments'
66 TAG_ATTACHMENTS = 'attachments'
67 TAG_ATTACHMENT = 'attachment'
67 TAG_ATTACHMENT = 'attachment'
68
68
69 TYPE_GET = 'get'
69 TYPE_GET = 'get'
70
70
71 ATTR_VERSION = 'version'
71 ATTR_VERSION = 'version'
72 ATTR_TYPE = 'type'
72 ATTR_TYPE = 'type'
73 ATTR_NAME = 'name'
73 ATTR_NAME = 'name'
74 ATTR_VALUE = 'value'
74 ATTR_VALUE = 'value'
75 ATTR_MIMETYPE = 'mimetype'
75 ATTR_MIMETYPE = 'mimetype'
76
76
77 STATUS_SUCCESS = 'success'
77 STATUS_SUCCESS = 'success'
78
78
79 PARAMETER_TRUNCATED = 'truncated'
79 PARAMETER_TRUNCATED = 'truncated'
80 PARAMETER_TAG = 'tag'
80 PARAMETER_TAG = 'tag'
81 PARAMETER_OFFSET = 'offset'
81 PARAMETER_OFFSET = 'offset'
82 PARAMETER_DIFF_TYPE = 'type'
82 PARAMETER_DIFF_TYPE = 'type'
83 PARAMETER_BUMPABLE = 'bumpable'
83 PARAMETER_BUMPABLE = 'bumpable'
84 PARAMETER_THREAD = 'thread'
84 PARAMETER_THREAD = 'thread'
85 PARAMETER_IS_OPENING = 'is_opening'
85 PARAMETER_IS_OPENING = 'is_opening'
86 PARAMETER_MODERATOR = 'moderator'
86 PARAMETER_MODERATOR = 'moderator'
87 PARAMETER_POST = 'post'
87 PARAMETER_POST = 'post'
88 PARAMETER_OP_ID = 'opening_post_id'
88 PARAMETER_OP_ID = 'opening_post_id'
89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
90
90
91 DIFF_TYPE_HTML = 'html'
91 DIFF_TYPE_HTML = 'html'
92 DIFF_TYPE_JSON = 'json'
92 DIFF_TYPE_JSON = 'json'
93
93
94 PREPARSE_PATTERNS = {
94 PREPARSE_PATTERNS = {
95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
98 }
98 }
99
99
100
100
101 class PostManager(models.Manager):
101 class PostManager(models.Manager):
102 @transaction.atomic
102 @transaction.atomic
103 def create_post(self, title: str, text: str, image=None, thread=None,
103 def create_post(self, title: str, text: str, image=None, thread=None,
104 ip=NO_IP, tags: list=None):
104 ip=NO_IP, tags: list=None):
105 """
105 """
106 Creates new post
106 Creates new post
107 """
107 """
108
108
109 if not tags:
109 if not tags:
110 tags = []
110 tags = []
111
111
112 posting_time = timezone.now()
112 posting_time = timezone.now()
113 if not thread:
113 if not thread:
114 thread = Thread.objects.create(bump_time=posting_time,
114 thread = Thread.objects.create(bump_time=posting_time,
115 last_edit_time=posting_time)
115 last_edit_time=posting_time)
116 new_thread = True
116 new_thread = True
117 else:
117 else:
118 new_thread = False
118 new_thread = False
119
119
120 pre_text = self._preparse_text(text)
120 pre_text = self._preparse_text(text)
121
121
122 post = self.create(title=title,
122 post = self.create(title=title,
123 text=pre_text,
123 text=pre_text,
124 pub_time=posting_time,
124 pub_time=posting_time,
125 thread_new=thread,
125 thread_new=thread,
126 poster_ip=ip,
126 poster_ip=ip,
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
128 # last!
128 # last!
129 last_edit_time=posting_time)
129 last_edit_time=posting_time)
130
130
131 post.set_global_id()
131 post.set_global_id()
132
132
133 logger = logging.getLogger('boards.post.create')
133 logger = logging.getLogger('boards.post.create')
134
134
135 logger.info('Created post {} by {}'.format(
135 logger.info('Created post {} by {}'.format(
136 post, post.poster_ip))
136 post, post.poster_ip))
137
137
138 if image:
138 if image:
139 post_image = PostImage.objects.create(image=image)
139 post_image = PostImage.objects.create(image=image)
140 post.images.add(post_image)
140 post.images.add(post_image)
141 logger.info('Created image #{} for post #{}'.format(
141 logger.info('Created image #{} for post #{}'.format(
142 post_image.id, post.id))
142 post_image.id, post.id))
143
143
144 thread.replies.add(post)
144 thread.replies.add(post)
145 list(map(thread.add_tag, tags))
145 list(map(thread.add_tag, tags))
146
146
147 if new_thread:
147 if new_thread:
148 Thread.objects.process_oldest_threads()
148 Thread.objects.process_oldest_threads()
149 else:
149 else:
150 thread.bump()
150 thread.bump()
151 thread.last_edit_time = posting_time
151 thread.last_edit_time = posting_time
152 thread.save()
152 thread.save()
153
153
154 self.connect_replies(post)
154 self.connect_replies(post)
155
155
156 return post
156 return post
157
157
158 def delete_posts_by_ip(self, ip):
158 def delete_posts_by_ip(self, ip):
159 """
159 """
160 Deletes all posts of the author with same IP
160 Deletes all posts of the author with same IP
161 """
161 """
162
162
163 posts = self.filter(poster_ip=ip)
163 posts = self.filter(poster_ip=ip)
164 for post in posts:
164 for post in posts:
165 post.delete()
165 post.delete()
166
166
167 # TODO This can be moved into a post
167 # TODO This can be moved into a post
168 def connect_replies(self, post):
168 def connect_replies(self, post):
169 """
169 """
170 Connects replies to a post to show them as a reflink map
170 Connects replies to a post to show them as a reflink map
171 """
171 """
172
172
173 for reply_number in post.get_replied_ids():
173 for reply_number in post.get_replied_ids():
174 ref_post = self.filter(id=reply_number)
174 ref_post = self.filter(id=reply_number)
175 if ref_post.count() > 0:
175 if ref_post.count() > 0:
176 referenced_post = ref_post[0]
176 referenced_post = ref_post[0]
177 referenced_post.referenced_posts.add(post)
177 referenced_post.referenced_posts.add(post)
178 referenced_post.last_edit_time = post.pub_time
178 referenced_post.last_edit_time = post.pub_time
179 referenced_post.build_refmap()
179 referenced_post.build_refmap()
180 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
180 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
181
181
182 referenced_thread = referenced_post.get_thread()
182 referenced_thread = referenced_post.get_thread()
183 referenced_thread.last_edit_time = post.pub_time
183 referenced_thread.last_edit_time = post.pub_time
184 referenced_thread.save(update_fields=['last_edit_time'])
184 referenced_thread.save(update_fields=['last_edit_time'])
185
185
186 def get_posts_per_day(self):
186 def get_posts_per_day(self):
187 """
187 """
188 Gets average count of posts per day for the last 7 days
188 Gets average count of posts per day for the last 7 days
189 """
189 """
190
190
191 day_end = date.today()
191 day_end = date.today()
192 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
192 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
193
193
194 cache_key = CACHE_KEY_PPD + str(day_end)
194 cache_key = CACHE_KEY_PPD + str(day_end)
195 ppd = cache.get(cache_key)
195 ppd = cache.get(cache_key)
196 if ppd:
196 if ppd:
197 return ppd
197 return ppd
198
198
199 day_time_start = timezone.make_aware(datetime.combine(
199 day_time_start = timezone.make_aware(datetime.combine(
200 day_start, dtime()), timezone.get_current_timezone())
200 day_start, dtime()), timezone.get_current_timezone())
201 day_time_end = timezone.make_aware(datetime.combine(
201 day_time_end = timezone.make_aware(datetime.combine(
202 day_end, dtime()), timezone.get_current_timezone())
202 day_end, dtime()), timezone.get_current_timezone())
203
203
204 posts_per_period = float(self.filter(
204 posts_per_period = float(self.filter(
205 pub_time__lte=day_time_end,
205 pub_time__lte=day_time_end,
206 pub_time__gte=day_time_start).count())
206 pub_time__gte=day_time_start).count())
207
207
208 ppd = posts_per_period / POSTS_PER_DAY_RANGE
208 ppd = posts_per_period / POSTS_PER_DAY_RANGE
209
209
210 cache.set(cache_key, ppd)
210 cache.set(cache_key, ppd)
211 return ppd
211 return ppd
212
212
213 # TODO Make a separate sync facade?
213 # TODO Make a separate sync facade?
214 def generate_response_get(self, model_list: list):
214 def generate_response_get(self, model_list: list):
215 response = et.Element(TAG_RESPONSE)
215 response = et.Element(TAG_RESPONSE)
216
216
217 status = et.SubElement(response, TAG_STATUS)
217 status = et.SubElement(response, TAG_STATUS)
218 status.text = STATUS_SUCCESS
218 status.text = STATUS_SUCCESS
219
219
220 models = et.SubElement(response, TAG_MODELS)
220 models = et.SubElement(response, TAG_MODELS)
221
221
222 for post in model_list:
222 for post in model_list:
223 model = et.SubElement(models, TAG_MODEL)
223 model = et.SubElement(models, TAG_MODEL)
224 model.set(ATTR_NAME, 'post')
224 model.set(ATTR_NAME, 'post')
225
225
226 content_tag = et.SubElement(model, TAG_CONTENT)
226 content_tag = et.SubElement(model, TAG_CONTENT)
227
227
228 tag_id = et.SubElement(content_tag, TAG_ID)
228 tag_id = et.SubElement(content_tag, TAG_ID)
229 post.global_id.to_xml_element(tag_id)
229 post.global_id.to_xml_element(tag_id)
230
230
231 title = et.SubElement(content_tag, TAG_TITLE)
231 title = et.SubElement(content_tag, TAG_TITLE)
232 title.text = post.title
232 title.text = post.title
233
233
234 text = et.SubElement(content_tag, TAG_TEXT)
234 text = et.SubElement(content_tag, TAG_TEXT)
235 # TODO Replace local links by global ones in the text
235 # TODO Replace local links by global ones in the text
236 text.text = post.get_raw_text()
236 text.text = post.get_raw_text()
237
237
238 if not post.is_opening():
238 if not post.is_opening():
239 thread = et.SubElement(content_tag, TAG_THREAD)
239 thread = et.SubElement(content_tag, TAG_THREAD)
240 thread.text = str(post.get_thread().get_opening_post_id())
240 thread_id = et.SubElement(thread, TAG_ID)
241 post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
241 else:
242 else:
242 # TODO Output tags here
243 # TODO Output tags here
243 pass
244 pass
244
245
245 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
246 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
246 pub_time.text = str(post.get_pub_time_epoch())
247 pub_time.text = str(post.get_pub_time_epoch())
247
248
248 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
249 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
249 post_signatures = post.signature.all()
250 post_signatures = post.signature.all()
250 if post_signatures:
251 if post_signatures:
251 signatures = post.signatures
252 signatures = post.signatures
252 else:
253 else:
253 # TODO Maybe the signature can be computed only once after
254 # TODO Maybe the signature can be computed only once after
254 # the post is added? Need to add some on_save signal queue
255 # the post is added? Need to add some on_save signal queue
255 # and add this there.
256 # and add this there.
256 key = KeyPair.objects.get(public_key=post.global_id.key)
257 key = KeyPair.objects.get(public_key=post.global_id.key)
257 signatures = [Signature(
258 signatures = [Signature(
258 key_type=key.key_type,
259 key_type=key.key_type,
259 key=key.public_key,
260 key=key.public_key,
260 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
261 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
261 )]
262 )]
262 for signature in signatures:
263 for signature in signatures:
263 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
264 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
264 signature_tag.set(ATTR_TYPE, signature.key_type)
265 signature_tag.set(ATTR_TYPE, signature.key_type)
265 signature_tag.set(ATTR_VALUE, signature.signature)
266 signature_tag.set(ATTR_VALUE, signature.signature)
266
267
267 return et.tostring(response, ENCODING_UNICODE)
268 return et.tostring(response, ENCODING_UNICODE)
268
269
269 def parse_response_get(self, response_xml):
270 def parse_response_get(self, response_xml):
270 tag_root = et.fromstring(response_xml)
271 tag_root = et.fromstring(response_xml)
271 tag_status = tag_root[0]
272 tag_status = tag_root[0]
272 if 'success' == tag_status.text:
273 if 'success' == tag_status.text:
273 tag_models = tag_root[1]
274 tag_models = tag_root[1]
274 for tag_model in tag_models:
275 for tag_model in tag_models:
275 tag_content = tag_model[0]
276 tag_content = tag_model[0]
276 tag_id = tag_content[1]
277 tag_id = tag_content[1]
277 try:
278 try:
278 GlobalId.from_xml_element(tag_id, existing=True)
279 GlobalId.from_xml_element(tag_id, existing=True)
279 # If this post already exists, just continue
280 # If this post already exists, just continue
280 # TODO Compare post content and update the post if necessary
281 # TODO Compare post content and update the post if necessary
281 pass
282 pass
282 except GlobalId.DoesNotExist:
283 except GlobalId.DoesNotExist:
283 global_id = GlobalId.from_xml_element(tag_id)
284 global_id = GlobalId.from_xml_element(tag_id)
284
285
285 title = tag_content.find(TAG_TITLE).text
286 title = tag_content.find(TAG_TITLE).text
286 text = tag_content.find(TAG_TEXT).text
287 text = tag_content.find(TAG_TEXT).text
287 # TODO Check that the replied posts are already present
288 # TODO Check that the replied posts are already present
288 # before adding new ones
289 # before adding new ones
289
290
290 # TODO Pub time, thread, tags
291 # TODO Pub time, thread, tags
291
292
292 post = Post.objects.create(title=title, text=text)
293 post = Post.objects.create(title=title, text=text)
293 else:
294 else:
294 # TODO Throw an exception?
295 # TODO Throw an exception?
295 pass
296 pass
296
297
297 def _preparse_text(self, text):
298 def _preparse_text(self, text):
298 """
299 """
299 Preparses text to change patterns like '>>' to a proper bbcode
300 Preparses text to change patterns like '>>' to a proper bbcode
300 tags.
301 tags.
301 """
302 """
302
303
303 for key, value in PREPARSE_PATTERNS.items():
304 for key, value in PREPARSE_PATTERNS.items():
304 text = re.sub(key, value, text, flags=re.MULTILINE)
305 text = re.sub(key, value, text, flags=re.MULTILINE)
305
306
306 return text
307 return text
307
308
308
309
309 class Post(models.Model, Viewable):
310 class Post(models.Model, Viewable):
310 """A post is a message."""
311 """A post is a message."""
311
312
312 objects = PostManager()
313 objects = PostManager()
313
314
314 class Meta:
315 class Meta:
315 app_label = APP_LABEL_BOARDS
316 app_label = APP_LABEL_BOARDS
316 ordering = ('id',)
317 ordering = ('id',)
317
318
318 title = models.CharField(max_length=TITLE_MAX_LENGTH)
319 title = models.CharField(max_length=TITLE_MAX_LENGTH)
319 pub_time = models.DateTimeField()
320 pub_time = models.DateTimeField()
320 text = TextField(blank=True, null=True)
321 text = TextField(blank=True, null=True)
321 _text_rendered = TextField(blank=True, null=True, editable=False)
322 _text_rendered = TextField(blank=True, null=True, editable=False)
322
323
323 images = models.ManyToManyField(PostImage, null=True, blank=True,
324 images = models.ManyToManyField(PostImage, null=True, blank=True,
324 related_name='ip+', db_index=True)
325 related_name='ip+', db_index=True)
325
326
326 poster_ip = models.GenericIPAddressField()
327 poster_ip = models.GenericIPAddressField()
327 poster_user_agent = models.TextField()
328 poster_user_agent = models.TextField()
328
329
329 thread_new = models.ForeignKey('Thread', null=True, default=None,
330 thread_new = models.ForeignKey('Thread', null=True, default=None,
330 db_index=True)
331 db_index=True)
331 last_edit_time = models.DateTimeField()
332 last_edit_time = models.DateTimeField()
332
333
333 # Replies to the post
334 # Replies to the post
334 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
335 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
335 null=True,
336 null=True,
336 blank=True, related_name='rfp+',
337 blank=True, related_name='rfp+',
337 db_index=True)
338 db_index=True)
338
339
339 # Replies map. This is built from the referenced posts list to speed up
340 # Replies map. This is built from the referenced posts list to speed up
340 # page loading (no need to get all the referenced posts from the database).
341 # page loading (no need to get all the referenced posts from the database).
341 refmap = models.TextField(null=True, blank=True)
342 refmap = models.TextField(null=True, blank=True)
342
343
343 # Global ID with author key. If the message was downloaded from another
344 # Global ID with author key. If the message was downloaded from another
344 # server, this indicates the server.
345 # server, this indicates the server.
345 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
346 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
346
347
347 # One post can be signed by many nodes that give their trust to it
348 # One post can be signed by many nodes that give their trust to it
348 signature = models.ManyToManyField('Signature', null=True, blank=True)
349 signature = models.ManyToManyField('Signature', null=True, blank=True)
349
350
350 def __str__(self):
351 def __str__(self):
351 return 'P#{}/{}'.format(self.id, self.title)
352 return 'P#{}/{}'.format(self.id, self.title)
352
353
353 def get_title(self) -> str:
354 def get_title(self) -> str:
354 """
355 """
355 Gets original post title or part of its text.
356 Gets original post title or part of its text.
356 """
357 """
357
358
358 title = self.title
359 title = self.title
359 if not title:
360 if not title:
360 title = self.get_text()
361 title = self.get_text()
361
362
362 return title
363 return title
363
364
364 def build_refmap(self) -> None:
365 def build_refmap(self) -> None:
365 """
366 """
366 Builds a replies map string from replies list. This is a cache to stop
367 Builds a replies map string from replies list. This is a cache to stop
367 the server from recalculating the map on every post show.
368 the server from recalculating the map on every post show.
368 """
369 """
369 map_string = ''
370 map_string = ''
370
371
371 first = True
372 first = True
372 for refpost in self.referenced_posts.all():
373 for refpost in self.referenced_posts.all():
373 if not first:
374 if not first:
374 map_string += ', '
375 map_string += ', '
375 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
376 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
376 refpost.id)
377 refpost.id)
377 first = False
378 first = False
378
379
379 self.refmap = map_string
380 self.refmap = map_string
380
381
381 def get_sorted_referenced_posts(self):
382 def get_sorted_referenced_posts(self):
382 return self.refmap
383 return self.refmap
383
384
384 def is_referenced(self) -> bool:
385 def is_referenced(self) -> bool:
385 if not self.refmap:
386 if not self.refmap:
386 return False
387 return False
387 else:
388 else:
388 return len(self.refmap) > 0
389 return len(self.refmap) > 0
389
390
390 def is_opening(self) -> bool:
391 def is_opening(self) -> bool:
391 """
392 """
392 Checks if this is an opening post or just a reply.
393 Checks if this is an opening post or just a reply.
393 """
394 """
394
395
395 return self.get_thread().get_opening_post_id() == self.id
396 return self.get_thread().get_opening_post_id() == self.id
396
397
397 @transaction.atomic
398 @transaction.atomic
398 def add_tag(self, tag):
399 def add_tag(self, tag):
399 edit_time = timezone.now()
400 edit_time = timezone.now()
400
401
401 thread = self.get_thread()
402 thread = self.get_thread()
402 thread.add_tag(tag)
403 thread.add_tag(tag)
403 self.last_edit_time = edit_time
404 self.last_edit_time = edit_time
404 self.save(update_fields=['last_edit_time'])
405 self.save(update_fields=['last_edit_time'])
405
406
406 thread.last_edit_time = edit_time
407 thread.last_edit_time = edit_time
407 thread.save(update_fields=['last_edit_time'])
408 thread.save(update_fields=['last_edit_time'])
408
409
409 def get_url(self, thread=None):
410 def get_url(self, thread=None):
410 """
411 """
411 Gets full url to the post.
412 Gets full url to the post.
412 """
413 """
413
414
414 cache_key = CACHE_KEY_POST_URL + str(self.id)
415 cache_key = CACHE_KEY_POST_URL + str(self.id)
415 link = cache.get(cache_key)
416 link = cache.get(cache_key)
416
417
417 if not link:
418 if not link:
418 if not thread:
419 if not thread:
419 thread = self.get_thread()
420 thread = self.get_thread()
420
421
421 opening_id = thread.get_opening_post_id()
422 opening_id = thread.get_opening_post_id()
422
423
423 if self.id != opening_id:
424 if self.id != opening_id:
424 link = reverse('thread', kwargs={
425 link = reverse('thread', kwargs={
425 'post_id': opening_id}) + '#' + str(self.id)
426 'post_id': opening_id}) + '#' + str(self.id)
426 else:
427 else:
427 link = reverse('thread', kwargs={'post_id': self.id})
428 link = reverse('thread', kwargs={'post_id': self.id})
428
429
429 cache.set(cache_key, link)
430 cache.set(cache_key, link)
430
431
431 return link
432 return link
432
433
433 def get_thread(self) -> Thread:
434 def get_thread(self) -> Thread:
434 """
435 """
435 Gets post's thread.
436 Gets post's thread.
436 """
437 """
437
438
438 return self.thread_new
439 return self.thread_new
439
440
440 def get_referenced_posts(self):
441 def get_referenced_posts(self):
441 return self.referenced_posts.only('id', 'thread_new')
442 return self.referenced_posts.only('id', 'thread_new')
442
443
443 def get_view(self, moderator=False, need_open_link=False,
444 def get_view(self, moderator=False, need_open_link=False,
444 truncated=False, *args, **kwargs):
445 truncated=False, *args, **kwargs):
445 """
446 """
446 Renders post's HTML view. Some of the post params can be passed over
447 Renders post's HTML view. Some of the post params can be passed over
447 kwargs for the means of caching (if we view the thread, some params
448 kwargs for the means of caching (if we view the thread, some params
448 are same for every post and don't need to be computed over and over.
449 are same for every post and don't need to be computed over and over.
449 """
450 """
450
451
451 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
452 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
452 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
453 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
453 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
454 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
454
455
455 if is_opening:
456 if is_opening:
456 opening_post_id = self.id
457 opening_post_id = self.id
457 else:
458 else:
458 opening_post_id = thread.get_opening_post_id()
459 opening_post_id = thread.get_opening_post_id()
459
460
460 return render_to_string('boards/post.html', {
461 return render_to_string('boards/post.html', {
461 PARAMETER_POST: self,
462 PARAMETER_POST: self,
462 PARAMETER_MODERATOR: moderator,
463 PARAMETER_MODERATOR: moderator,
463 PARAMETER_IS_OPENING: is_opening,
464 PARAMETER_IS_OPENING: is_opening,
464 PARAMETER_THREAD: thread,
465 PARAMETER_THREAD: thread,
465 PARAMETER_BUMPABLE: can_bump,
466 PARAMETER_BUMPABLE: can_bump,
466 PARAMETER_NEED_OPEN_LINK: need_open_link,
467 PARAMETER_NEED_OPEN_LINK: need_open_link,
467 PARAMETER_TRUNCATED: truncated,
468 PARAMETER_TRUNCATED: truncated,
468 PARAMETER_OP_ID: opening_post_id,
469 PARAMETER_OP_ID: opening_post_id,
469 })
470 })
470
471
471 def get_search_view(self, *args, **kwargs):
472 def get_search_view(self, *args, **kwargs):
472 return self.get_view(args, kwargs)
473 return self.get_view(args, kwargs)
473
474
474 def get_first_image(self) -> PostImage:
475 def get_first_image(self) -> PostImage:
475 return self.images.earliest('id')
476 return self.images.earliest('id')
476
477
477 def delete(self, using=None):
478 def delete(self, using=None):
478 """
479 """
479 Deletes all post images and the post itself. If the post is opening,
480 Deletes all post images and the post itself. If the post is opening,
480 thread with all posts is deleted.
481 thread with all posts is deleted.
481 """
482 """
482
483
483 self.images.all().delete()
484 self.images.all().delete()
484 self.signature.all().delete()
485 self.signature.all().delete()
485 if self.global_id:
486 if self.global_id:
486 self.global_id.delete()
487 self.global_id.delete()
487
488
488 if self.is_opening():
489 if self.is_opening():
489 self.get_thread().delete()
490 self.get_thread().delete()
490 else:
491 else:
491 thread = self.get_thread()
492 thread = self.get_thread()
492 thread.last_edit_time = timezone.now()
493 thread.last_edit_time = timezone.now()
493 thread.save()
494 thread.save()
494
495
495 super(Post, self).delete(using)
496 super(Post, self).delete(using)
496 logging.getLogger('boards.post.delete').info(
497 logging.getLogger('boards.post.delete').info(
497 'Deleted post {}'.format(self))
498 'Deleted post {}'.format(self))
498
499
499 def set_global_id(self, key_pair=None):
500 def set_global_id(self, key_pair=None):
500 """
501 """
501 Sets global id based on the given key pair. If no key pair is given,
502 Sets global id based on the given key pair. If no key pair is given,
502 default one is used.
503 default one is used.
503 """
504 """
504
505
505 if key_pair:
506 if key_pair:
506 key = key_pair
507 key = key_pair
507 else:
508 else:
508 try:
509 try:
509 key = KeyPair.objects.get(primary=True)
510 key = KeyPair.objects.get(primary=True)
510 except KeyPair.DoesNotExist:
511 except KeyPair.DoesNotExist:
511 # Do not update the global id because there is no key defined
512 # Do not update the global id because there is no key defined
512 return
513 return
513 global_id = GlobalId(key_type=key.key_type,
514 global_id = GlobalId(key_type=key.key_type,
514 key=key.public_key,
515 key=key.public_key,
515 local_id = self.id)
516 local_id = self.id)
516 global_id.save()
517 global_id.save()
517
518
518 self.global_id = global_id
519 self.global_id = global_id
519
520
520 self.save(update_fields=['global_id'])
521 self.save(update_fields=['global_id'])
521
522
522 def get_pub_time_epoch(self):
523 def get_pub_time_epoch(self):
523 return utils.datetime_to_epoch(self.pub_time)
524 return utils.datetime_to_epoch(self.pub_time)
524
525
525 def get_replied_ids(self):
526 def get_replied_ids(self):
526 """
527 """
527 Gets ID list of the posts that this post replies.
528 Gets ID list of the posts that this post replies.
528 """
529 """
529
530
530 raw_text = self.get_raw_text()
531 raw_text = self.get_raw_text()
531
532
532 local_replied = REGEX_REPLY.findall(raw_text)
533 local_replied = REGEX_REPLY.findall(raw_text)
533 global_replied = []
534 global_replied = []
534 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
535 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
535 key_type = match[0]
536 key_type = match[0]
536 key = match[1]
537 key = match[1]
537 local_id = match[2]
538 local_id = match[2]
538
539
539 try:
540 try:
540 global_id = GlobalId.objects.get(key_type=key_type,
541 global_id = GlobalId.objects.get(key_type=key_type,
541 key=key, local_id=local_id)
542 key=key, local_id=local_id)
542 for post in Post.objects.filter(global_id=global_id).only('id'):
543 for post in Post.objects.filter(global_id=global_id).only('id'):
543 global_replied.append(post.id)
544 global_replied.append(post.id)
544 except GlobalId.DoesNotExist:
545 except GlobalId.DoesNotExist:
545 pass
546 pass
546 return local_replied + global_replied
547 return local_replied + global_replied
547
548
548
549
549 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
550 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
550 include_last_update=False):
551 include_last_update=False):
551 """
552 """
552 Gets post HTML or JSON data that can be rendered on a page or used by
553 Gets post HTML or JSON data that can be rendered on a page or used by
553 API.
554 API.
554 """
555 """
555
556
556 if format_type == DIFF_TYPE_HTML:
557 if format_type == DIFF_TYPE_HTML:
557 params = dict()
558 params = dict()
558 params['post'] = self
559 params['post'] = self
559 if PARAMETER_TRUNCATED in request.GET:
560 if PARAMETER_TRUNCATED in request.GET:
560 params[PARAMETER_TRUNCATED] = True
561 params[PARAMETER_TRUNCATED] = True
561
562
562 return render_to_string('boards/api_post.html', params)
563 return render_to_string('boards/api_post.html', params)
563 elif format_type == DIFF_TYPE_JSON:
564 elif format_type == DIFF_TYPE_JSON:
564 post_json = {
565 post_json = {
565 'id': self.id,
566 'id': self.id,
566 'title': self.title,
567 'title': self.title,
567 'text': self._text_rendered,
568 'text': self._text_rendered,
568 }
569 }
569 if self.images.exists():
570 if self.images.exists():
570 post_image = self.get_first_image()
571 post_image = self.get_first_image()
571 post_json['image'] = post_image.image.url
572 post_json['image'] = post_image.image.url
572 post_json['image_preview'] = post_image.image.url_200x150
573 post_json['image_preview'] = post_image.image.url_200x150
573 if include_last_update:
574 if include_last_update:
574 post_json['bump_time'] = datetime_to_epoch(
575 post_json['bump_time'] = datetime_to_epoch(
575 self.thread_new.bump_time)
576 self.thread_new.bump_time)
576 return post_json
577 return post_json
577
578
578 def send_to_websocket(self, request, recursive=True):
579 def send_to_websocket(self, request, recursive=True):
579 """
580 """
580 Sends post HTML data to the thread web socket.
581 Sends post HTML data to the thread web socket.
581 """
582 """
582
583
583 if not settings.WEBSOCKETS_ENABLED:
584 if not settings.WEBSOCKETS_ENABLED:
584 return
585 return
585
586
586 client = Client()
587 client = Client()
587
588
588 thread = self.get_thread()
589 thread = self.get_thread()
589 thread_id = thread.id
590 thread_id = thread.id
590 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
591 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
591 client.publish(channel_name, {
592 client.publish(channel_name, {
592 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
593 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
593 })
594 })
594 client.send()
595 client.send()
595
596
596 logger = logging.getLogger('boards.post.websocket')
597 logger = logging.getLogger('boards.post.websocket')
597
598
598 logger.info('Sent notification from post #{} to channel {}'.format(
599 logger.info('Sent notification from post #{} to channel {}'.format(
599 self.id, channel_name))
600 self.id, channel_name))
600
601
601 if recursive:
602 if recursive:
602 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
603 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
603 post_id = reply_number.group(1)
604 post_id = reply_number.group(1)
604 ref_post = Post.objects.filter(id=post_id)[0]
605 ref_post = Post.objects.filter(id=post_id)[0]
605
606
606 # If post is in this thread, its thread was already notified.
607 # If post is in this thread, its thread was already notified.
607 # Otherwise, notify its thread separately.
608 # Otherwise, notify its thread separately.
608 if ref_post.thread_new_id != thread_id:
609 if ref_post.thread_new_id != thread_id:
609 ref_post.send_to_websocket(request, recursive=False)
610 ref_post.send_to_websocket(request, recursive=False)
610
611
611 def save(self, force_insert=False, force_update=False, using=None,
612 def save(self, force_insert=False, force_update=False, using=None,
612 update_fields=None):
613 update_fields=None):
613 self._text_rendered = bbcode_extended(self.get_raw_text())
614 self._text_rendered = bbcode_extended(self.get_raw_text())
614
615
615 super().save(force_insert, force_update, using, update_fields)
616 super().save(force_insert, force_update, using, update_fields)
616
617
617 def get_text(self) -> str:
618 def get_text(self) -> str:
618 return self._text_rendered
619 return self._text_rendered
619
620
620 def get_raw_text(self) -> str:
621 def get_raw_text(self) -> str:
621 return self.text
622 return self.text
@@ -1,85 +1,87 b''
1 from base64 import b64encode
1 from base64 import b64encode
2 import logging
2 import logging
3
3
4 from django.test import TestCase
4 from django.test import TestCase
5 from boards.models import KeyPair, GlobalId, Post
5 from boards.models import KeyPair, GlobalId, Post
6
6
7
7
8 logger = logging.getLogger(__name__)
8 logger = logging.getLogger(__name__)
9
9
10
10
11 class KeyTest(TestCase):
11 class KeyTest(TestCase):
12 def test_create_key(self):
12 def test_create_key(self):
13 key = KeyPair.objects.generate_key('ecdsa')
13 key = KeyPair.objects.generate_key('ecdsa')
14
14
15 self.assertIsNotNone(key, 'The key was not created.')
15 self.assertIsNotNone(key, 'The key was not created.')
16
16
17 def test_validation(self):
17 def test_validation(self):
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
19 message = 'msg'
19 message = 'msg'
20 signature = key.sign(message)
20 signature = key.sign(message)
21 valid = KeyPair.objects.verify(key.public_key, message, signature,
21 valid = KeyPair.objects.verify(key.public_key, message, signature,
22 key_type='ecdsa')
22 key_type='ecdsa')
23
23
24 self.assertTrue(valid, 'Message verification failed.')
24 self.assertTrue(valid, 'Message verification failed.')
25
25
26 def test_primary_constraint(self):
26 def test_primary_constraint(self):
27 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
27 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
28
28
29 with self.assertRaises(Exception):
29 with self.assertRaises(Exception):
30 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
30 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
31
31
32 def test_model_id_save(self):
32 def test_model_id_save(self):
33 model_id = GlobalId(key_type='test', key='test key', local_id='1')
33 model_id = GlobalId(key_type='test', key='test key', local_id='1')
34 model_id.save()
34 model_id.save()
35
35
36 def test_request_get(self):
36 def test_request_get(self):
37 post = self._create_post_with_key()
37 post = self._create_post_with_key()
38
38
39 request = GlobalId.objects.generate_request_get([post.global_id])
39 request = GlobalId.objects.generate_request_get([post.global_id])
40 logger.debug(request)
40 logger.debug(request)
41
41
42 key = KeyPair.objects.get(primary=True)
42 key = KeyPair.objects.get(primary=True)
43 self.assertTrue('<request type="get" version="1.0">'
43 self.assertTrue('<request type="get" version="1.0">'
44 '<model name="post" version="1.0">'
44 '<model name="post" version="1.0">'
45 '<id key="%s" local-id="1" type="%s" />'
45 '<id key="%s" local-id="1" type="%s" />'
46 '</model>'
46 '</model>'
47 '</request>' % (
47 '</request>' % (
48 key.public_key,
48 key.public_key,
49 key.key_type,
49 key.key_type,
50 ) in request,
50 ) in request,
51 'Wrong XML generated for the GET request.')
51 'Wrong XML generated for the GET request.')
52
52
53 def test_response_get(self):
53 def test_response_get(self):
54 post = self._create_post_with_key()
54 post = self._create_post_with_key()
55 reply_post = Post.objects.create_post(title='test_title',
55 reply_post = Post.objects.create_post(title='test_title',
56 text='[post]%d[/post]' % post.id,
56 text='[post]%d[/post]' % post.id,
57 thread=post.get_thread())
57 thread=post.get_thread())
58
58
59 response = Post.objects.generate_response_get([reply_post])
59 response = Post.objects.generate_response_get([reply_post])
60 logger.debug(response)
60 logger.debug(response)
61
61
62 key = KeyPair.objects.get(primary=True)
62 key = KeyPair.objects.get(primary=True)
63 self.assertTrue('<status>success</status>'
63 self.assertTrue('<status>success</status>'
64 '<models>'
64 '<models>'
65 '<model name="post">'
65 '<model name="post">'
66 '<content>'
66 '<content>'
67 '<id key="%s" local-id="%d" type="%s" />'
67 '<id key="%s" local-id="%d" type="%s" />'
68 '<title>test_title</title>'
68 '<title>test_title</title>'
69 '<text>[post]%d[/post]</text>'
69 '<text>[post]%d[/post]</text>'
70 '<thread>%d</thread>'
70 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
71 '<pub-time>%s</pub-time>'
71 '<pub-time>%s</pub-time>'
72 '</content>' % (
72 '</content>' % (
73 key.public_key,
73 key.public_key,
74 reply_post.id,
74 reply_post.id,
75 key.key_type,
75 key.key_type,
76 post.id,
76 post.id,
77 key.public_key,
77 post.id,
78 post.id,
79 key.key_type,
78 str(reply_post.get_pub_time_epoch()),
80 str(reply_post.get_pub_time_epoch()),
79 ) in response,
81 ) in response,
80 'Wrong XML generated for the GET response.')
82 'Wrong XML generated for the GET response.')
81
83
82 def _create_post_with_key(self):
84 def _create_post_with_key(self):
83 KeyPair.objects.generate_key(primary=True)
85 KeyPair.objects.generate_key(primary=True)
84
86
85 return Post.objects.create_post(title='test_title', text='test_text')
87 return Post.objects.create_post(title='test_title', text='test_text')
@@ -1,58 +1,58 b''
1 <?xml version="1.1" encoding="UTF-8" ?>
1 <?xml version="1.1" encoding="UTF-8" ?>
2 <response>
2 <response>
3 <!--
3 <!--
4 Valid statuses are 'success' and 'error'.
4 Valid statuses are 'success' and 'error'.
5 -->
5 -->
6 <status>success</status>
6 <status>success</status>
7 <models>
7 <models>
8 <model name="post">
8 <model name="post">
9 <!--
9 <!--
10 Content tag is the data that is signed by signatures and must
10 Content tag is the data that is signed by signatures and must
11 not be changed for the post from other node.
11 not be changed for the post from other node.
12 -->
12 -->
13 <content>
13 <content>
14 <id key="id1" type="ecdsa" local-id="1" />
14 <id key="id1" type="ecdsa" local-id="1" />
15 <title>13</title>
15 <title>13</title>
16 <text>Thirteen</text>
16 <text>Thirteen</text>
17 <thread>id1/12</thread>
17 <thread><id key="id1" type="ecdsa" local-id="2" /></thread>
18 <pub-time>12</pub-time>
18 <pub-time>12</pub-time>
19 <!--
19 <!--
20 Images are saved as attachments and included in the
20 Images are saved as attachments and included in the
21 signature.
21 signature.
22 -->
22 -->
23 <attachments>
23 <attachments>
24 <attachment mimetype="image/png" name="12345.png">
24 <attachment mimetype="image/png" name="12345.png">
25 TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0
25 TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0
26 aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1
26 aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1
27 c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0
27 c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0
28 aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdl
28 aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdl
29 LCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=
29 LCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=
30 </attachment>
30 </attachment>
31 </attachments>
31 </attachments>
32 </content>
32 </content>
33 <!--
33 <!--
34 There can be several signatures for one model. At least one
34 There can be several signatures for one model. At least one
35 signature must be made with the key used in global ID.
35 signature must be made with the key used in global ID.
36 -->
36 -->
37 <signatures>
37 <signatures>
38 <signature key="id1" type="ecdsa" value="dhefhtreh" />
38 <signature key="id1" type="ecdsa" value="dhefhtreh" />
39 <signature key="id45" type="ecdsa" value="dsgfgdhefhtreh" />
39 <signature key="id45" type="ecdsa" value="dsgfgdhefhtreh" />
40 </signatures>
40 </signatures>
41 </model>
41 </model>
42 <model name="post">
42 <model name="post">
43 <content>
43 <content>
44 <id key="id1" type="ecdsa" local-id="id2" />
44 <id key="id1" type="ecdsa" local-id="id2" />
45 <title>13</title>
45 <title>13</title>
46 <text>Thirteen</text>
46 <text>Thirteen</text>
47 <pub-time>12</pub-time>
47 <pub-time>12</pub-time>
48 <edit-time>13</edit-time>
48 <edit-time>13</edit-time>
49 <tags>
49 <tags>
50 <tag>tag1</tag>
50 <tag>tag1</tag>
51 </tags>
51 </tags>
52 </content>
52 </content>
53 <signatures>
53 <signatures>
54 <signature key="id2" type="ecdsa" value="dehdfh" />
54 <signature key="id2" type="ecdsa" value="dehdfh" />
55 </signatures>
55 </signatures>
56 </model>
56 </model>
57 </models>
57 </models>
58 </response>
58 </response>
General Comments 0
You need to be logged in to leave comments. Login now