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