##// END OF EJS Templates
Added test for reflinks. Added management command to get posts from other node...
neko259 -
r841:c295c39c decentral
parent child Browse files
Show More
@@ -0,0 +1,37 b''
1 import re
2 import urllib
3 import httplib2
4 from django.core.management import BaseCommand
5 from boards.models import GlobalId
6
7 __author__ = 'neko259'
8
9
10 REGEX_GLOBAL_ID = r'\[(\w+)\]\[(\w+)\]\[(\d+)\]'
11
12
13 class Command(BaseCommand):
14 help = 'Send a sync or get request to the server.' + \
15 'sync_with_server <server_url> [post_global_id]'
16
17 def handle(self, *args, **options):
18 url = args[0]
19 if len(args) > 1:
20 global_id_str = args[1]
21 match = re.match(REGEX_GLOBAL_ID, global_id_str)
22 key_type = match.group(1)
23 key = match.group(2)
24 local_id = match.group(3)
25
26 global_id = GlobalId(key_type=key_type, key=key,
27 local_id=local_id)
28
29 xml = GlobalId.objects.generate_request_get([global_id])
30 data = {'xml': xml}
31 body = urllib.urlencode(data)
32 h = httplib2.Http()
33 response, content = h.request(url, method="POST", body=body)
34
35 # TODO Parse content and get the model list
36 else:
37 raise Exception('Full sync is not supported yet.')
@@ -0,0 +1,3 b''
1 #! /usr/bin/env sh
2
3 python3 manage.py runserver [::]:8000
@@ -0,0 +1,3 b''
1 #! /usr/bin/env sh
2
3 python3 manage.py test
@@ -1,182 +1,201 b''
1 1 # coding=utf-8
2 2
3 3 import re
4 4 import bbcode
5 5
6 6 import boards
7 7
8 8
9 9 __author__ = 'neko259'
10 10
11 11
12 12 REFLINK_PATTERN = re.compile(r'^\d+$')
13 GLOBAL_REFLINK_PATTERN = re.compile(r'^(\w+)::([^:]+)::(\d+)$')
13 14 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
14 15 ONE_NEWLINE = '\n'
15 16
16 17
17 18 class TextFormatter():
18 19 """
19 20 An interface for formatter that can be used in the text format panel
20 21 """
21 22
22 23 def __init__(self):
23 24 pass
24 25
25 26 name = ''
26 27
27 28 # Left and right tags for the button preview
28 29 preview_left = ''
29 30 preview_right = ''
30 31
31 32 # Left and right characters for the textarea input
32 33 format_left = ''
33 34 format_right = ''
34 35
35 36
36 37 class AutolinkPattern():
37 38 def handleMatch(self, m):
38 39 link_element = etree.Element('a')
39 40 href = m.group(2)
40 41 link_element.set('href', href)
41 42 link_element.text = href
42 43
43 44 return link_element
44 45
45 46
46 47 class QuotePattern(TextFormatter):
47 48 name = 'q'
48 49 preview_left = '<span class="multiquote">'
49 50 preview_right = '</span>'
50 51
51 52 format_left = '[quote]'
52 53 format_right = '[/quote]'
53 54
54 55
55 56 class SpoilerPattern(TextFormatter):
56 57 name = 'spoiler'
57 58 preview_left = '<span class="spoiler">'
58 59 preview_right = '</span>'
59 60
60 61 format_left = '[spoiler]'
61 62 format_right = '[/spoiler]'
62 63
63 64 def handleMatch(self, m):
64 65 quote_element = etree.Element('span')
65 66 quote_element.set('class', 'spoiler')
66 67 quote_element.text = m.group(2)
67 68
68 69 return quote_element
69 70
70 71
71 72 class CommentPattern(TextFormatter):
72 73 name = ''
73 74 preview_left = '<span class="comment">// '
74 75 preview_right = '</span>'
75 76
76 77 format_left = '[comment]'
77 78 format_right = '[/comment]'
78 79
79 80
80 81 # TODO Use <s> tag here
81 82 class StrikeThroughPattern(TextFormatter):
82 83 name = 's'
83 84 preview_left = '<span class="strikethrough">'
84 85 preview_right = '</span>'
85 86
86 87 format_left = '[s]'
87 88 format_right = '[/s]'
88 89
89 90
90 91 class ItalicPattern(TextFormatter):
91 92 name = 'i'
92 93 preview_left = '<i>'
93 94 preview_right = '</i>'
94 95
95 96 format_left = '[i]'
96 97 format_right = '[/i]'
97 98
98 99
99 100 class BoldPattern(TextFormatter):
100 101 name = 'b'
101 102 preview_left = '<b>'
102 103 preview_right = '</b>'
103 104
104 105 format_left = '[b]'
105 106 format_right = '[/b]'
106 107
107 108
108 109 class CodePattern(TextFormatter):
109 110 name = 'code'
110 111 preview_left = '<code>'
111 112 preview_right = '</code>'
112 113
113 114 format_left = '[code]'
114 115 format_right = '[/code]'
115 116
116 117
117 118 def render_reflink(tag_name, value, options, parent, context):
118 if not REFLINK_PATTERN.match(value):
119 return '>>%s' % value
119 post_id = None
120 120
121 post_id = int(value)
121 matches = REFLINK_PATTERN.findall(value)
122 if matches:
123 post_id = int(matches[0][0])
124 else:
125 match = GLOBAL_REFLINK_PATTERN.match(value)
126 if match:
127 key_type = match.group(1)
128 key = match.group(2)
129 local_id = match.group(3)
130
131 try:
132 global_id = boards.models.GlobalId.objects.get(key_type=key_type,
133 key=key, local_id=local_id)
134 for post in boards.models.Post.objects.filter(global_id=global_id).only('id'):
135 post_id = post.id
136 except boards.models.GlobalId.DoesNotExist:
137 pass
138
139 if not post_id:
140 return value
122 141
123 142 posts = boards.models.Post.objects.filter(id=post_id)
124 143 if posts.exists():
125 144 post = posts[0]
126 145
127 146 return '<a href="%s">&gt;&gt;%s</a>' % (post.get_url(), post_id)
128 147 else:
129 148 return '>>%s' % value
130 149
131 150
132 151 def render_quote(tag_name, value, options, parent, context):
133 152 source = ''
134 153 if 'source' in options:
135 154 source = options['source']
136 155
137 156 result = ''
138 157 if source:
139 158 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
140 159 else:
141 160 result = '<div class="multiquote"><div class="quote-text">%s</div></div>' % value
142 161
143 162 return result
144 163
145 164
146 165 def preparse_text(text):
147 166 """
148 167 Performs manual parsing before the bbcode parser is used.
149 168 """
150 169
151 170 return MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
152 171
153 172
154 173 def bbcode_extended(markup):
155 174 # The newline hack is added because br's margin does not work in all
156 175 # browsers except firefox, when the div's does.
157 176 parser = bbcode.Parser(newline='<div class="br"></div>')
158 177 parser.add_formatter('post', render_reflink, strip=True)
159 178 parser.add_formatter('quote', render_quote, strip=True)
160 179 parser.add_simple_formatter('comment',
161 180 u'<span class="comment">//%(value)s</span>')
162 181 parser.add_simple_formatter('spoiler',
163 182 u'<span class="spoiler">%(value)s</span>')
164 183 # TODO Use <s> here
165 184 parser.add_simple_formatter('s',
166 185 u'<span class="strikethrough">%(value)s</span>')
167 186 # TODO Why not use built-in tag?
168 187 parser.add_simple_formatter('code',
169 188 u'<pre><code>%(value)s</pre></code>', render_embedded=False)
170 189
171 190 text = preparse_text(markup)
172 191 return parser.format(text)
173 192
174 193 formatters = [
175 194 QuotePattern,
176 195 SpoilerPattern,
177 196 ItalicPattern,
178 197 BoldPattern,
179 198 CommentPattern,
180 199 StrikeThroughPattern,
181 200 CodePattern,
182 201 ]
@@ -1,498 +1,518 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 import re
5 5 import xml.etree.ElementTree as et
6 6
7 7 from django.core.cache import cache
8 8 from django.core.urlresolvers import reverse
9 9 from django.db import models, transaction
10 10 from django.template.loader import render_to_string
11 11 from django.utils import timezone
12 12
13 13 from markupfield.fields import MarkupField
14 14
15 15 from boards.models import PostImage, KeyPair, GlobalId, Signature
16 16 from boards.models.base import Viewable
17 17 from boards.models.thread import Thread
18 18 from boards import utils
19 19
20 ENCODING_UNICODE = 'unicode'
20 21
21 22 APP_LABEL_BOARDS = 'boards'
22 23
23 24 CACHE_KEY_PPD = 'ppd'
24 25 CACHE_KEY_POST_URL = 'post_url'
25 26
26 27 POSTS_PER_DAY_RANGE = 7
27 28
28 29 BAN_REASON_AUTO = 'Auto'
29 30
30 31 IMAGE_THUMB_SIZE = (200, 150)
31 32
32 33 TITLE_MAX_LENGTH = 200
33 34
34 35 DEFAULT_MARKUP_TYPE = 'bbcode'
35 36
36 37 # TODO This should be removed
37 38 NO_IP = '0.0.0.0'
38 39
39 40 # TODO Real user agent should be saved instead of this
40 41 UNKNOWN_UA = ''
41 42
42 43 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
44 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
43 45
44 46 TAG_MODEL = 'model'
45 47 TAG_REQUEST = 'request'
46 48 TAG_RESPONSE = 'response'
47 49 TAG_ID = 'id'
48 50 TAG_STATUS = 'status'
49 51 TAG_MODELS = 'models'
50 52 TAG_TITLE = 'title'
51 53 TAG_TEXT = 'text'
52 54 TAG_THREAD = 'thread'
53 55 TAG_PUB_TIME = 'pub-time'
54 TAG_EDIT_TIME = 'edit-time'
55 TAG_PREVIOUS = 'previous'
56 TAG_NEXT = 'next'
57 56 TAG_SIGNATURES = 'signatures'
58 57 TAG_SIGNATURE = 'signature'
59 58 TAG_CONTENT = 'content'
60 59 TAG_ATTACHMENTS = 'attachments'
61 60 TAG_ATTACHMENT = 'attachment'
62 61
63 62 TYPE_GET = 'get'
64 63
65 64 ATTR_VERSION = 'version'
66 65 ATTR_TYPE = 'type'
67 66 ATTR_NAME = 'name'
68 67 ATTR_VALUE = 'value'
69 68 ATTR_MIMETYPE = 'mimetype'
70 69
71 70 STATUS_SUCCESS = 'success'
72 71
73 72 logger = logging.getLogger(__name__)
74 73
75 74
76 75 class PostManager(models.Manager):
77 76 def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
78 77 tags=None):
79 78 """
80 79 Creates new post
81 80 """
82 81
83 82 if not tags:
84 83 tags = []
85 84
86 85 posting_time = timezone.now()
87 86 if not thread:
88 87 thread = Thread.objects.create(bump_time=posting_time,
89 88 last_edit_time=posting_time)
90 89 new_thread = True
91 90 else:
92 91 thread.bump()
93 92 thread.last_edit_time = posting_time
94 93 thread.save()
95 94 new_thread = False
96 95
97 96 post = self.create(title=title,
98 97 text=text,
99 98 pub_time=posting_time,
100 99 thread_new=thread,
101 100 poster_ip=ip,
102 101 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
103 102 # last!
104 103 last_edit_time=posting_time)
105 104
106 105 post.set_global_id()
107 106
108 107 if image:
109 108 post_image = PostImage.objects.create(image=image)
110 109 post.images.add(post_image)
111 110 logger.info('Created image #%d for post #%d' % (post_image.id,
112 111 post.id))
113 112
114 113 thread.replies.add(post)
115 114 list(map(thread.add_tag, tags))
116 115
117 116 if new_thread:
118 117 Thread.objects.process_oldest_threads()
119 118 self.connect_replies(post)
120 119
121 120 logger.info('Created post #%d with title %s'
122 121 % (post.id, post.get_title()))
123 122
124 123 return post
125 124
126 125 def delete_post(self, post):
127 126 """
128 127 Deletes post and update or delete its thread
129 128 """
130 129
131 130 post_id = post.id
132 131
133 132 thread = post.get_thread()
134 133
135 134 if post.is_opening():
136 135 thread.delete()
137 136 else:
138 137 thread.last_edit_time = timezone.now()
139 138 thread.save()
140 139
141 140 post.delete()
142 141
143 142 logger.info('Deleted post #%d (%s)' % (post_id, post.get_title()))
144 143
145 144 def delete_posts_by_ip(self, ip):
146 145 """
147 146 Deletes all posts of the author with same IP
148 147 """
149 148
150 149 posts = self.filter(poster_ip=ip)
151 150 for post in posts:
152 151 self.delete_post(post)
153 152
153 # TODO This can be moved into a post
154 154 def connect_replies(self, post):
155 155 """
156 156 Connects replies to a post to show them as a reflink map
157 157 """
158 158
159 159 for reply_number in post.get_replied_ids():
160 160 ref_post = self.filter(id=reply_number)
161 161 if ref_post.count() > 0:
162 162 referenced_post = ref_post[0]
163 163 referenced_post.referenced_posts.add(post)
164 164 referenced_post.last_edit_time = post.pub_time
165 165 referenced_post.build_refmap()
166 166 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
167 167
168 168 referenced_thread = referenced_post.get_thread()
169 169 referenced_thread.last_edit_time = post.pub_time
170 170 referenced_thread.save(update_fields=['last_edit_time'])
171 171
172 172 def get_posts_per_day(self):
173 173 """
174 174 Gets average count of posts per day for the last 7 days
175 175 """
176 176
177 177 day_end = date.today()
178 178 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
179 179
180 180 cache_key = CACHE_KEY_PPD + str(day_end)
181 181 ppd = cache.get(cache_key)
182 182 if ppd:
183 183 return ppd
184 184
185 185 day_time_start = timezone.make_aware(datetime.combine(
186 186 day_start, dtime()), timezone.get_current_timezone())
187 187 day_time_end = timezone.make_aware(datetime.combine(
188 188 day_end, dtime()), timezone.get_current_timezone())
189 189
190 190 posts_per_period = float(self.filter(
191 191 pub_time__lte=day_time_end,
192 192 pub_time__gte=day_time_start).count())
193 193
194 194 ppd = posts_per_period / POSTS_PER_DAY_RANGE
195 195
196 196 cache.set(cache_key, ppd)
197 197 return ppd
198 198
199 def generate_request_get(self, model_list: list):
200 """
201 Form a get request from a list of ModelId objects.
202 """
203
204 request = et.Element(TAG_REQUEST)
205 request.set(ATTR_TYPE, TYPE_GET)
206 request.set(ATTR_VERSION, '1.0')
207
208 model = et.SubElement(request, TAG_MODEL)
209 model.set(ATTR_VERSION, '1.0')
210 model.set(ATTR_NAME, 'post')
211
212 for post in model_list:
213 tag_id = et.SubElement(model, TAG_ID)
214 post.global_id.to_xml_element(tag_id)
215
216 return et.tostring(request, 'unicode')
217
199 # TODO Make a separate sync facade?
218 200 def generate_response_get(self, model_list: list):
219 201 response = et.Element(TAG_RESPONSE)
220 202
221 203 status = et.SubElement(response, TAG_STATUS)
222 204 status.text = STATUS_SUCCESS
223 205
224 206 models = et.SubElement(response, TAG_MODELS)
225 207
226 208 for post in model_list:
227 209 model = et.SubElement(models, TAG_MODEL)
228 210 model.set(ATTR_NAME, 'post')
229 211
230 212 content_tag = et.SubElement(model, TAG_CONTENT)
231 213
232 214 tag_id = et.SubElement(content_tag, TAG_ID)
233 215 post.global_id.to_xml_element(tag_id)
234 216
235 217 title = et.SubElement(content_tag, TAG_TITLE)
236 218 title.text = post.title
237 219
238 220 text = et.SubElement(content_tag, TAG_TEXT)
221 # TODO Replace local links by global ones in the text
239 222 text.text = post.text.raw
240 223
241 224 if not post.is_opening():
242 225 thread = et.SubElement(content_tag, TAG_THREAD)
243 226 thread.text = str(post.get_thread().get_opening_post_id())
244 227 else:
245 228 # TODO Output tags here
246 229 pass
247 230
248 231 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
249 232 pub_time.text = str(post.get_pub_time_epoch())
250 233
251 234 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
252 235 post_signatures = post.signature.all()
253 236 if post_signatures:
254 237 signatures = post.signatures
255 238 else:
256 239 # TODO Maybe the signature can be computed only once after
257 240 # the post is added? Need to add some on_save signal queue
258 241 # and add this there.
259 242 key = KeyPair.objects.get(public_key=post.global_id.key)
260 243 signatures = [Signature(
261 244 key_type=key.key_type,
262 245 key=key.public_key,
263 signature=key.sign(et.tostring(model, 'unicode')),
246 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
264 247 )]
265 248 for signature in signatures:
266 249 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
267 250 signature_tag.set(ATTR_TYPE, signature.key_type)
268 251 signature_tag.set(ATTR_VALUE, signature.signature)
269 252
270 return et.tostring(response, 'unicode')
253 return et.tostring(response, ENCODING_UNICODE)
254
255 def parse_response_get(self, response_xml):
256 tag_root = et.fromstring(response_xml)
257 tag_status = tag_root[0]
258 if 'success' == tag_status.text:
259 tag_models = tag_root[1]
260 for tag_model in tag_models:
261 tag_content = tag_model[0]
262 tag_id = tag_content[1]
263 try:
264 GlobalId.from_xml_element(tag_id, existing=True)
265 # If this post already exists, just continue
266 # TODO Compare post content and update the post if necessary
267 pass
268 except GlobalId.DoesNotExist:
269 global_id = GlobalId.from_xml_element(tag_id)
270
271 title = tag_content.find(TAG_TITLE).text
272 text = tag_content.find(TAG_TEXT).text
273 # TODO Check that the replied posts are already present
274 # before adding new ones
275
276 # TODO Pub time, thread, tags
277
278 post = Post.objects.create(title=title, text=text)
279 else:
280 # TODO Throw an exception?
281 pass
271 282
272 283
273 284 class Post(models.Model, Viewable):
274 285 """A post is a message."""
275 286
276 287 objects = PostManager()
277 288
278 289 class Meta:
279 290 app_label = APP_LABEL_BOARDS
280 291 ordering = ('id',)
281 292
282 293 title = models.CharField(max_length=TITLE_MAX_LENGTH)
283 294 pub_time = models.DateTimeField()
284 295 text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE,
285 296 escape_html=False)
286 297
287 298 images = models.ManyToManyField(PostImage, null=True, blank=True,
288 299 related_name='ip+', db_index=True)
289 300
290 301 poster_ip = models.GenericIPAddressField()
291 302 poster_user_agent = models.TextField()
292 303
293 304 thread_new = models.ForeignKey('Thread', null=True, default=None,
294 305 db_index=True)
295 306 last_edit_time = models.DateTimeField()
296 307
297 308 # Replies to the post
298 309 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
299 310 null=True,
300 311 blank=True, related_name='rfp+',
301 312 db_index=True)
302 313
303 314 # Replies map. This is built from the referenced posts list to speed up
304 315 # page loading (no need to get all the referenced posts from the database).
305 316 refmap = models.TextField(null=True, blank=True)
306 317
307 318 # Global ID with author key. If the message was downloaded from another
308 319 # server, this indicates the server.
309 320 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
310 321
311 322 # One post can be signed by many nodes that give their trust to it
312 323 signature = models.ManyToManyField('Signature', null=True, blank=True)
313 324
314 325 def __unicode__(self):
315 326 return '#' + str(self.id) + ' ' + self.title + ' (' + \
316 327 self.text.raw[:50] + ')'
317 328
318 329 def get_title(self):
319 """
320 Gets original post title or part of its text.
321 """
322
323 title = self.title
324 if not title:
325 title = self.text.rendered
326
327 return title
330 return self.title
328 331
329 332 def build_refmap(self):
330 333 """
331 334 Builds a replies map string from replies list. This is a cache to stop
332 335 the server from recalculating the map on every post show.
333 336 """
334 337 map_string = ''
335 338
336 339 first = True
337 340 for refpost in self.referenced_posts.all():
338 341 if not first:
339 342 map_string += ', '
340 343 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
341 344 refpost.id)
342 345 first = False
343 346
344 347 self.refmap = map_string
345 348
346 349 def get_sorted_referenced_posts(self):
347 350 return self.refmap
348 351
349 352 def is_referenced(self):
350 353 return len(self.refmap) > 0
351 354
352 355 def is_opening(self):
353 356 """
354 357 Checks if this is an opening post or just a reply.
355 358 """
356 359
357 360 return self.get_thread().get_opening_post_id() == self.id
358 361
359 362 @transaction.atomic
360 363 def add_tag(self, tag):
361 364 edit_time = timezone.now()
362 365
363 366 thread = self.get_thread()
364 367 thread.add_tag(tag)
365 368 self.last_edit_time = edit_time
366 369 self.save(update_fields=['last_edit_time'])
367 370
368 371 thread.last_edit_time = edit_time
369 372 thread.save(update_fields=['last_edit_time'])
370 373
371 374 @transaction.atomic
372 375 def remove_tag(self, tag):
373 376 edit_time = timezone.now()
374 377
375 378 thread = self.get_thread()
376 379 thread.remove_tag(tag)
377 380 self.last_edit_time = edit_time
378 381 self.save(update_fields=['last_edit_time'])
379 382
380 383 thread.last_edit_time = edit_time
381 384 thread.save(update_fields=['last_edit_time'])
382 385
383 386 def get_url(self, thread=None):
384 387 """
385 388 Gets full url to the post.
386 389 """
387 390
388 391 cache_key = CACHE_KEY_POST_URL + str(self.id)
389 392 link = cache.get(cache_key)
390 393
391 394 if not link:
392 395 if not thread:
393 396 thread = self.get_thread()
394 397
395 398 opening_id = thread.get_opening_post_id()
396 399
397 400 if self.id != opening_id:
398 401 link = reverse('thread', kwargs={
399 402 'post_id': opening_id}) + '#' + str(self.id)
400 403 else:
401 404 link = reverse('thread', kwargs={'post_id': self.id})
402 405
403 406 cache.set(cache_key, link)
404 407
405 408 return link
406 409
407 410 def get_thread(self):
408 411 """
409 412 Gets post's thread.
410 413 """
411 414
412 415 return self.thread_new
413 416
414 417 def get_referenced_posts(self):
415 418 return self.referenced_posts.only('id', 'thread_new')
416 419
417 420 def get_text(self):
418 421 return self.text
419 422
420 423 def get_view(self, moderator=False, need_open_link=False,
421 424 truncated=False, *args, **kwargs):
422 425 if 'is_opening' in kwargs:
423 426 is_opening = kwargs['is_opening']
424 427 else:
425 428 is_opening = self.is_opening()
426 429
427 430 if 'thread' in kwargs:
428 431 thread = kwargs['thread']
429 432 else:
430 433 thread = self.get_thread()
431 434
432 435 if 'can_bump' in kwargs:
433 436 can_bump = kwargs['can_bump']
434 437 else:
435 438 can_bump = thread.can_bump()
436 439
437 440 if is_opening:
438 441 opening_post_id = self.id
439 442 else:
440 443 opening_post_id = thread.get_opening_post_id()
441 444
442 445 return render_to_string('boards/post.html', {
443 446 'post': self,
444 447 'moderator': moderator,
445 448 'is_opening': is_opening,
446 449 'thread': thread,
447 450 'bumpable': can_bump,
448 451 'need_open_link': need_open_link,
449 452 'truncated': truncated,
450 453 'opening_post_id': opening_post_id,
451 454 })
452 455
453 456 def get_first_image(self):
454 457 return self.images.earliest('id')
455 458
456 459 def delete(self, using=None):
457 460 """
458 461 Deletes all post images and the post itself.
459 462 """
460 463
461 464 self.images.all().delete()
462 465 self.signature.all().delete()
463 466 if self.global_id:
464 467 self.global_id.delete()
465 468
466 469 super(Post, self).delete(using)
467 470
468 471 def set_global_id(self, key_pair=None):
469 472 """
470 473 Sets global id based on the given key pair. If no key pair is given,
471 474 default one is used.
472 475 """
473 476
474 477 if key_pair:
475 478 key = key_pair
476 479 else:
477 480 try:
478 481 key = KeyPair.objects.get(primary=True)
479 482 except KeyPair.DoesNotExist:
480 483 # Do not update the global id because there is no key defined
481 484 return
482 485 global_id = GlobalId(key_type=key.key_type,
483 486 key=key.public_key,
484 487 local_id = self.id)
485 488 global_id.save()
486 489
487 490 self.global_id = global_id
488 491
489 492 self.save(update_fields=['global_id'])
490 493
491 494 def get_pub_time_epoch(self):
492 495 return utils.datetime_to_epoch(self.pub_time)
493 496
494 def get_edit_time_epoch(self):
495 return utils.datetime_to_epoch(self.last_edit_time)
497 def get_replied_ids(self):
498 """
499 Gets ID list of the posts that this post replies.
500 """
496 501
497 def get_replied_ids(self):
498 return re.findall(REGEX_REPLY, self.text.raw)
502 local_replied = REGEX_REPLY.findall(self.text.raw)
503 global_replied = []
504 # TODO Similar code is used in mdx_neboard, maybe it can be extracted
505 # into a method?
506 for match in REGEX_GLOBAL_REPLY.findall(self.text.raw):
507 key_type = match[0]
508 key = match[1]
509 local_id = match[2]
510
511 try:
512 global_id = GlobalId.objects.get(key_type=key_type,
513 key=key, local_id=local_id)
514 for post in Post.objects.filter(global_id=global_id).only('id'):
515 global_replied.append(post.id)
516 except GlobalId.DoesNotExist:
517 pass
518 return local_replied + global_replied
@@ -1,75 +1,108 b''
1 1 import xml.etree.ElementTree as et
2 2 from django.db import models
3 3
4 4
5 TAG_MODEL = 'model'
6 TAG_REQUEST = 'request'
7 TAG_ID = 'id'
8
9 TYPE_GET = 'get'
10
11 ATTR_VERSION = 'version'
12 ATTR_TYPE = 'type'
13 ATTR_NAME = 'name'
14
5 15 ATTR_KEY = 'key'
6 16 ATTR_KEY_TYPE = 'type'
7 17 ATTR_LOCAL_ID = 'local-id'
8 18
9 19
20 class GlobalIdManager(models.Manager):
21 def generate_request_get(self, global_id_list: list):
22 """
23 Form a get request from a list of ModelId objects.
24 """
25
26 request = et.Element(TAG_REQUEST)
27 request.set(ATTR_TYPE, TYPE_GET)
28 request.set(ATTR_VERSION, '1.0')
29
30 model = et.SubElement(request, TAG_MODEL)
31 model.set(ATTR_VERSION, '1.0')
32 model.set(ATTR_NAME, 'post')
33
34 for global_id in global_id_list:
35 tag_id = et.SubElement(model, TAG_ID)
36 global_id.to_xml_element(tag_id)
37
38 return et.tostring(request, 'unicode')
39
40
10 41 class GlobalId(models.Model):
11 42 class Meta:
12 43 app_label = 'boards'
13 44
45 objects = GlobalIdManager()
46
14 47 def __init__(self, *args, **kwargs):
15 48 models.Model.__init__(self, *args, **kwargs)
16 49
17 50 if 'key' in kwargs and 'key_type' in kwargs and 'local_id' in kwargs:
18 51 self.key = kwargs['key']
19 52 self.key_type = kwargs['key_type']
20 53 self.local_id = kwargs['local_id']
21 54
22 55 key = models.TextField()
23 56 key_type = models.TextField()
24 57 local_id = models.IntegerField()
25 58
26 59 def __str__(self):
27 return '[%s][%s][%d]' % (self.key_type, self.key, self.local_id)
60 return '%s::%s::%d' % (self.key_type, self.key, self.local_id)
28 61
29 62 def to_xml_element(self, element: et.Element):
30 63 """
31 64 Exports global id to an XML element.
32 65 """
33 66
34 67 element.set(ATTR_KEY, self.key)
35 68 element.set(ATTR_KEY_TYPE, self.key_type)
36 69 element.set(ATTR_LOCAL_ID, str(self.local_id))
37 70
38 71 @staticmethod
39 72 def from_xml_element(element: et.Element, existing=False):
40 73 """
41 74 Parses XML id tag and gets global id from it.
42 75
43 76 Arguments:
44 77 element -- the XML 'id' element
45 78 existing -- if this is False, a new instance of GlobalId will be
46 79 created. Otherwise, we will search for an existing GlobalId instance
47 80 and throw DoesNotExist if there isn't one.
48 81 """
49 82
50 83 if existing:
51 84 return GlobalId.objects.get(key=element.get(ATTR_KEY),
52 85 key_type=element.get(ATTR_KEY_TYPE),
53 86 local_id=int(element.get(
54 87 ATTR_LOCAL_ID)))
55 88 else:
56 89 return GlobalId(key=element.get(ATTR_KEY),
57 90 key_type=element.get(ATTR_KEY_TYPE),
58 91 local_id=int(element.get(ATTR_LOCAL_ID)))
59 92
60 93
61 94 class Signature(models.Model):
62 95 class Meta:
63 96 app_label = 'boards'
64 97
65 98 def __init__(self, *args, **kwargs):
66 99 models.Model.__init__(self, *args, **kwargs)
67 100
68 101 if 'key' in kwargs and 'key_type' in kwargs and 'signature' in kwargs:
69 102 self.key_type = kwargs['key_type']
70 103 self.key = kwargs['key']
71 104 self.signature = kwargs['signature']
72 105
73 106 key_type = models.TextField()
74 107 key = models.TextField()
75 108 signature = models.TextField()
@@ -1,61 +1,61 b''
1 1 import base64
2 2 from ecdsa import SigningKey, VerifyingKey, BadSignatureError
3 3 from django.db import models
4 4
5 5 TYPE_ECDSA = 'ecdsa'
6 6
7 7 APP_LABEL_BOARDS = 'boards'
8 8
9 9
10 10 class KeyPairManager(models.Manager):
11 11 def generate_key(self, key_type=TYPE_ECDSA, primary=False):
12 12 if primary and self.filter(primary=True).exists():
13 13 raise Exception('There can be only one primary key')
14 14
15 15 if key_type == TYPE_ECDSA:
16 16 private = SigningKey.generate()
17 17 public = private.get_verifying_key()
18 18
19 19 private_key_str = base64.b64encode(private.to_string()).decode()
20 20 public_key_str = base64.b64encode(public.to_string()).decode()
21 21
22 22 return self.create(public_key=public_key_str,
23 23 private_key=private_key_str,
24 24 key_type=TYPE_ECDSA, primary=primary)
25 25 else:
26 26 raise Exception('Key type not supported')
27 27
28 28 def verify(self, public_key_str, string, signature, key_type=TYPE_ECDSA):
29 29 if key_type == TYPE_ECDSA:
30 30 public = VerifyingKey.from_string(base64.b64decode(public_key_str))
31 31 signature_byte = base64.b64decode(signature)
32 32 try:
33 33 return public.verify(signature_byte, string.encode())
34 34 except BadSignatureError:
35 35 return False
36 36 else:
37 37 raise Exception('Key type not supported')
38 38
39 39 def has_primary(self):
40 40 return self.filter(primary=True).exists()
41 41
42 42
43 43 class KeyPair(models.Model):
44 44 class Meta:
45 45 app_label = APP_LABEL_BOARDS
46 46
47 47 objects = KeyPairManager()
48 48
49 49 public_key = models.TextField()
50 50 private_key = models.TextField()
51 51 key_type = models.TextField()
52 52 primary = models.BooleanField(default=False)
53 53
54 54 def __str__(self):
55 return '[%s][%s]' % (self.key_type, self.public_key)
55 return '%s::%s' % (self.key_type, self.public_key)
56 56
57 57 def sign(self, string):
58 58 private = SigningKey.from_string(base64.b64decode(
59 59 self.private_key.encode()))
60 60 signature_byte = private.sign_deterministic(string.encode())
61 61 return base64.b64encode(signature_byte).decode()
@@ -1,85 +1,85 b''
1 1 from base64 import b64encode
2 2 import logging
3 3
4 4 from django.test import TestCase
5 5 from boards.models import KeyPair, GlobalId, Post
6 6
7 7
8 8 logger = logging.getLogger(__name__)
9 9
10 10
11 11 class KeyTest(TestCase):
12 12 def test_create_key(self):
13 13 key = KeyPair.objects.generate_key('ecdsa')
14 14
15 15 self.assertIsNotNone(key, 'The key was not created.')
16 16
17 17 def test_validation(self):
18 18 key = KeyPair.objects.generate_key(key_type='ecdsa')
19 19 message = 'msg'
20 20 signature = key.sign(message)
21 21 valid = KeyPair.objects.verify(key.public_key, message, signature,
22 22 key_type='ecdsa')
23 23
24 24 self.assertTrue(valid, 'Message verification failed.')
25 25
26 26 def test_primary_constraint(self):
27 27 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
28 28
29 29 with self.assertRaises(Exception):
30 30 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
31 31
32 32 def test_model_id_save(self):
33 33 model_id = GlobalId(key_type='test', key='test key', local_id='1')
34 34 model_id.save()
35 35
36 36 def test_request_get(self):
37 37 post = self._create_post_with_key()
38 38
39 request = Post.objects.generate_request_get([post])
39 request = GlobalId.objects.generate_request_get([post.global_id])
40 40 logger.debug(request)
41 41
42 42 key = KeyPair.objects.get(primary=True)
43 43 self.assertTrue('<request type="get" version="1.0">'
44 44 '<model name="post" version="1.0">'
45 45 '<id key="%s" local-id="1" type="%s" />'
46 46 '</model>'
47 47 '</request>' % (
48 48 key.public_key,
49 49 key.key_type,
50 50 ) in request,
51 51 'Wrong XML generated for the GET request.')
52 52
53 53 def test_response_get(self):
54 54 post = self._create_post_with_key()
55 55 reply_post = Post.objects.create_post(title='test_title',
56 56 text='[post]%d[/post]' % post.id,
57 57 thread=post.get_thread())
58 58
59 59 response = Post.objects.generate_response_get([reply_post])
60 60 logger.debug(response)
61 61
62 62 key = KeyPair.objects.get(primary=True)
63 63 self.assertTrue('<status>success</status>'
64 64 '<models>'
65 65 '<model name="post">'
66 66 '<content>'
67 67 '<id key="%s" local-id="%d" type="%s" />'
68 68 '<title>test_title</title>'
69 69 '<text>[post]%d[/post]</text>'
70 70 '<thread>%d</thread>'
71 71 '<pub-time>%s</pub-time>'
72 72 '</content>' % (
73 73 key.public_key,
74 74 reply_post.id,
75 75 key.key_type,
76 76 post.id,
77 77 post.id,
78 78 str(reply_post.get_pub_time_epoch()),
79 79 ) in response,
80 80 'Wrong XML generated for the GET response.')
81 81
82 82 def _create_post_with_key(self):
83 83 KeyPair.objects.generate_key(primary=True)
84 84
85 85 return Post.objects.create_post(title='test_title', text='test_text')
@@ -1,112 +1,142 b''
1 1 from django.core.paginator import Paginator
2 2 from django.test import TestCase
3 3 from boards import settings
4 from boards.models import Tag, Post, Thread
4 from boards.models import Tag, Post, Thread, KeyPair
5 5
6 6
7 7 class PostTests(TestCase):
8 8
9 9 def _create_post(self):
10 10 tag = Tag.objects.create(name='test_tag')
11 11 return Post.objects.create_post(title='title', text='text',
12 12 tags=[tag])
13 13
14 14 def test_post_add(self):
15 15 """Test adding post"""
16 16
17 17 post = self._create_post()
18 18
19 19 self.assertIsNotNone(post, 'No post was created.')
20 20 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
21 21 'No tags were added to the post.')
22 22
23 23 def test_delete_post(self):
24 24 """Test post deletion"""
25 25
26 26 post = self._create_post()
27 27 post_id = post.id
28 28
29 29 Post.objects.delete_post(post)
30 30
31 31 self.assertFalse(Post.objects.filter(id=post_id).exists())
32 32
33 33 def test_delete_thread(self):
34 34 """Test thread deletion"""
35 35
36 36 opening_post = self._create_post()
37 37 thread = opening_post.get_thread()
38 38 reply = Post.objects.create_post("", "", thread=thread)
39 39
40 40 thread.delete()
41 41
42 42 self.assertFalse(Post.objects.filter(id=reply.id).exists())
43 43
44 44 def test_post_to_thread(self):
45 45 """Test adding post to a thread"""
46 46
47 47 op = self._create_post()
48 48 post = Post.objects.create_post("", "", thread=op.get_thread())
49 49
50 50 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
51 51 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
52 52 'Post\'s create time doesn\'t match thread last edit'
53 53 ' time')
54 54
55 55 def test_delete_posts_by_ip(self):
56 56 """Test deleting posts with the given ip"""
57 57
58 58 post = self._create_post()
59 59 post_id = post.id
60 60
61 61 Post.objects.delete_posts_by_ip('0.0.0.0')
62 62
63 63 self.assertFalse(Post.objects.filter(id=post_id).exists())
64 64
65 65 def test_get_thread(self):
66 66 """Test getting all posts of a thread"""
67 67
68 68 opening_post = self._create_post()
69 69
70 70 for i in range(2):
71 71 Post.objects.create_post('title', 'text',
72 72 thread=opening_post.get_thread())
73 73
74 74 thread = opening_post.get_thread()
75 75
76 76 self.assertEqual(3, thread.replies.count())
77 77
78 78 def test_create_post_with_tag(self):
79 79 """Test adding tag to post"""
80 80
81 81 tag = Tag.objects.create(name='test_tag')
82 82 post = Post.objects.create_post(title='title', text='text', tags=[tag])
83 83
84 84 thread = post.get_thread()
85 85 self.assertIsNotNone(post, 'Post not created')
86 86 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
87 87 self.assertTrue(thread in tag.threads.all(), 'Thread not added to tag')
88 88
89 89 def test_thread_max_count(self):
90 90 """Test deletion of old posts when the max thread count is reached"""
91 91
92 92 for i in range(settings.MAX_THREAD_COUNT + 1):
93 93 self._create_post()
94 94
95 95 self.assertEqual(settings.MAX_THREAD_COUNT,
96 96 len(Thread.objects.filter(archived=False)))
97 97
98 98 def test_pages(self):
99 99 """Test that the thread list is properly split into pages"""
100 100
101 101 for i in range(settings.MAX_THREAD_COUNT):
102 102 self._create_post()
103 103
104 104 all_threads = Thread.objects.filter(archived=False)
105 105
106 106 paginator = Paginator(Thread.objects.filter(archived=False),
107 107 settings.THREADS_PER_PAGE)
108 108 posts_in_second_page = paginator.page(2).object_list
109 109 first_post = posts_in_second_page[0]
110 110
111 111 self.assertEqual(all_threads[settings.THREADS_PER_PAGE].id,
112 first_post.id) No newline at end of file
112 first_post.id)
113
114 def test_reflinks(self):
115 """
116 Tests that reflinks are parsed within post and connecting replies
117 to the replied posts.
118
119 Local reflink example: [post]123[/post]
120 Global reflink example: [post]key_type::key::123[/post]
121 """
122
123 key = KeyPair.objects.generate_key(primary=True)
124
125 tag = Tag.objects.create(name='test_tag')
126
127 post = Post.objects.create_post(title='', text='', tags=[tag])
128 post_local_reflink = Post.objects.create_post(title='',
129 text='[post]%d[/post]' % post.id, thread=post.get_thread())
130
131 self.assertTrue(post_local_reflink in post.referenced_posts.all(),
132 'Local reflink not connecting posts.')
133
134 post_global_reflink = Post.objects.create_post(title='',
135 text='[post]%s::%s::%d[/post]' % (
136 post.global_id.key_type, post.global_id.key, post.id),
137 thread=post.get_thread())
138
139 self.assertTrue(post_global_reflink in post.referenced_posts.all(),
140 'Global reflink not connecting posts.')
141
142 # TODO Check that links are parsed into the rendered text
@@ -1,9 +1,10 b''
1 httplib2
1 2 simplejson
2 3 south>=0.8.4
3 4 haystack
4 5 pillow
5 6 django>=1.6
6 7 django_cleanup
7 8 django-markupfield
8 9 bbcode
9 10 ecdsa
@@ -1,25 +1,26 b''
1 1 = Features =
2 2 * Tree view (JS)
3 3 * Adding tags to images filename
4 4 * Federative network for s2s communication
5 5 * XMPP gate
6 6 * Bitmessage gate
7 7 * Notification engine
8 8 * Group tags by first letter in all tags list
9 9 * [JS] Character counter in the post field
10 10 * Statistics module. Count views (optional, may result in bad
11 11 performance), posts per day/week/month, IPs
12 12 * Ban confirmation page (or alert) with reason
13 13 * Post deletion confirmation page (or alert)
14 14 * Get thread graph image using pygraphviz
15 15 * Subscribing to tag via AJAX
16 16 * Add buttons to insert a named link or a named quote to the markup panel
17 17 * Add support for "attention posts" that are shown in the header"
18 * Use absolute post reflinks in the raw text
18 19
19 20 = Bugs =
20 21 * Search sort order is confusing
21 22
22 23 = Testing =
23 24 * Make tests for every view
24 25 * Make tests for every model
25 26 * Make tests for every form
General Comments 0
You need to be logged in to leave comments. Login now