##// END OF EJS Templates
Merge tip
Bohdan Horbeshko -
r2144:1765cedc merge lite
parent child Browse files
Show More
@@ -0,0 +1,31 b''
1 import xml.etree.ElementTree as et
2
3 from boards.models import Post
4
5 TAG_THREAD = 'thread'
6
7
8 class PostFilter:
9 def __init__(self, content=None):
10 self.content = content
11
12 def filter(self, posts):
13 return posts
14
15 def add_filter(self, model_tag, value):
16 return model_tag
17
18
19 class ThreadFilter(PostFilter):
20 def filter(self, posts):
21 op_id = self.content.text
22
23 op = Post.objects.filter(opening=True, id=op_id).first()
24 if op:
25 return posts.filter(thread=op.get_thread())
26 else:
27 return posts.none()
28
29 def add_filter(self, model_tag, value):
30 thread_tag = et.SubElement(model_tag, TAG_THREAD)
31 thread_tag.text = str(value)
@@ -0,0 +1,24 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.10.5 on 2017-01-23 14:20
3 from __future__ import unicode_literals
4
5 from django.db import migrations, models
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0055_auto_20161229_1132'),
12 ]
13
14 operations = [
15 migrations.AlterModelOptions(
16 name='attachment',
17 options={'ordering': ('id',)},
18 ),
19 migrations.AlterField(
20 model_name='post',
21 name='uid',
22 field=models.TextField(),
23 ),
24 ]
@@ -1,48 +1,49 b''
1 [Version]
1 [Version]
2 Version = 4.1.0 2017
2 Version = 4.1.0 2017
3 SiteName = Neboard DEV
3 SiteName = Neboard DEV
4
4
5 [Cache]
5 [Cache]
6 # Timeout for caching, if cache is used
6 # Timeout for caching, if cache is used
7 CacheTimeout = 600
7 CacheTimeout = 600
8
8
9 [Forms]
9 [Forms]
10 # Max post length in characters
10 # Max post length in characters
11 MaxTextLength = 30000
11 MaxTextLength = 30000
12 MaxFileSize = 8000000
12 MaxFileSize = 8000000
13 LimitFirstPosting = true
13 LimitFirstPosting = true
14 LimitPostingSpeed = false
14 LimitPostingSpeed = false
15 PowDifficulty = 0
15 PowDifficulty = 0
16 # Delay in seconds
16 # Delay in seconds
17 PostingDelay = 30
17 PostingDelay = 30
18 Autoban = false
18 Autoban = false
19 DefaultTag = test
19 DefaultTag = test
20 MaxFileCount = 1
20 MaxFileCount = 1
21 AdditionalSpoilerSpaces = false
21
22
22 [Messages]
23 [Messages]
23 # Thread bumplimit
24 # Thread bumplimit
24 MaxPostsPerThread = 10
25 MaxPostsPerThread = 10
25 ThreadArchiveDays = 300
26 ThreadArchiveDays = 300
26 AnonymousMode = false
27 AnonymousMode = false
27
28
28 [View]
29 [View]
29 DefaultTheme = md
30 DefaultTheme = md
30 DefaultImageViewer = simple
31 DefaultImageViewer = simple
31 LastRepliesCount = 3
32 LastRepliesCount = 3
32 ThreadsPerPage = 3
33 ThreadsPerPage = 3
33 PostsPerPage = 10
34 PostsPerPage = 10
34 ImagesPerPageGallery = 20
35 ImagesPerPageGallery = 20
35 MaxFavoriteThreads = 20
36 MaxFavoriteThreads = 20
36 MaxLandingThreads = 20
37 MaxLandingThreads = 20
37 Themes=md:Mystic Dark,md_centered:Mystic Dark (centered),sw:Snow White,pg:Photon Grey,ad:Amanita Dark,iw:Inocibe White
38 Themes=md:Mystic Dark,md_centered:Mystic Dark (centered),sw:Snow White,pg:Photon Grey,ad:Amanita Dark,iw:Inocibe White
38 ImageViewers=simple:Simple,popup:Popup
39 ImageViewers=simple:Simple,popup:Popup
39
40
40 [Storage]
41 [Storage]
41 # Enable archiving threads instead of deletion when the thread limit is reached
42 # Enable archiving threads instead of deletion when the thread limit is reached
42 ArchiveThreads = true
43 ArchiveThreads = true
43
44
44 [RSS]
45 [RSS]
45 MaxItems = 20
46 MaxItems = 20
46
47
47 [External]
48 [External]
48 ImageSearchHost=
49 ImageSearchHost=
@@ -1,96 +1,99 b''
1 import re
1 import re
2 import logging
2 import logging
3 import xml.etree.ElementTree as ET
3 import xml.etree.ElementTree as ET
4
4
5 import httplib2
5 import httplib2
6 from django.core.management import BaseCommand
6 from django.core.management import BaseCommand
7
7
8 from boards.models import GlobalId
8 from boards.models import GlobalId
9 from boards.models.post.sync import SyncManager, TAG_ID, TAG_VERSION
9 from boards.models.post.sync import SyncManager, TAG_ID, TAG_VERSION
10
10
11 __author__ = 'neko259'
11 __author__ = 'neko259'
12
12
13
13
14 REGEX_GLOBAL_ID = re.compile(r'(\w+)::([\w\+/]+)::(\d+)')
14 REGEX_GLOBAL_ID = re.compile(r'(\w+)::([\w\+/]+)::(\d+)')
15
15
16
16
17 class Command(BaseCommand):
17 class Command(BaseCommand):
18 help = 'Send a sync or get request to the server.'
18 help = 'Send a sync or get request to the server.'
19
19
20 def add_arguments(self, parser):
20 def add_arguments(self, parser):
21 parser.add_argument('url', type=str, help='Server root url')
21 parser.add_argument('url', type=str, help='Server root url')
22 parser.add_argument('--global-id', type=str, default='',
22 parser.add_argument('--global-id', type=str, default='',
23 help='Post global ID')
23 help='Post global ID')
24 parser.add_argument('--split-query', type=int, default=1,
24 parser.add_argument('--split-query', type=int, default=1,
25 help='Split GET query into separate by the given'
25 help='Split GET query into separate by the given'
26 ' number of posts in one')
26 ' number of posts in one')
27 parser.add_argument('--thread', type=int,
28 help='Get posts of one specific thread')
27
29
28 def handle(self, *args, **options):
30 def handle(self, *args, **options):
29 logger = logging.getLogger('boards.sync')
31 logger = logging.getLogger('boards.sync')
30
32
31 url = options.get('url')
33 url = options.get('url')
32
34
33 list_url = url + 'api/sync/list/'
35 list_url = url + 'api/sync/list/'
34 get_url = url + 'api/sync/get/'
36 get_url = url + 'api/sync/get/'
35 file_url = url[:-1]
37 file_url = url[:-1]
36
38
37 global_id_str = options.get('global_id')
39 global_id_str = options.get('global_id')
38 if global_id_str:
40 if global_id_str:
39 match = REGEX_GLOBAL_ID.match(global_id_str)
41 match = REGEX_GLOBAL_ID.match(global_id_str)
40 if match:
42 if match:
41 key_type = match.group(1)
43 key_type = match.group(1)
42 key = match.group(2)
44 key = match.group(2)
43 local_id = match.group(3)
45 local_id = match.group(3)
44
46
45 global_id = GlobalId(key_type=key_type, key=key,
47 global_id = GlobalId(key_type=key_type, key=key,
46 local_id=local_id)
48 local_id=local_id)
47
49
48 xml = GlobalId.objects.generate_request_get([global_id])
50 xml = SyncManager.generate_request_get([global_id])
49 h = httplib2.Http()
51 h = httplib2.Http()
50 response, content = h.request(get_url, method="POST", body=xml)
52 response, content = h.request(get_url, method="POST", body=xml)
51
53
52 SyncManager.parse_response_get(content, file_url)
54 SyncManager.parse_response_get(content, file_url)
53 else:
55 else:
54 raise Exception('Invalid global ID')
56 raise Exception('Invalid global ID')
55 else:
57 else:
56 logger.info('Running LIST request...')
58 logger.info('Running LIST request...')
57 h = httplib2.Http()
59 h = httplib2.Http()
58 xml = GlobalId.objects.generate_request_list()
60 xml = SyncManager.generate_request_list(
61 opening_post=options.get('thread'))
59 response, content = h.request(list_url, method="POST", body=xml)
62 response, content = h.request(list_url, method="POST", body=xml)
60 logger.info('Processing response...')
63 logger.info('Processing response...')
61
64
62 root = ET.fromstring(content)
65 root = ET.fromstring(content)
63 status = root.findall('status')[0].text
66 status = root.findall('status')[0].text
64 if status == 'success':
67 if status == 'success':
65 ids_to_sync = list()
68 ids_to_sync = list()
66
69
67 models = root.findall('models')[0]
70 models = root.findall('models')[0]
68 for model in models:
71 for model in models:
69 tag_id = model.find(TAG_ID)
72 tag_id = model.find(TAG_ID)
70 global_id, exists = GlobalId.from_xml_element(tag_id)
73 global_id, exists = GlobalId.from_xml_element(tag_id)
71 tag_version = model.find(TAG_VERSION)
74 tag_version = model.find(TAG_VERSION)
72 if tag_version is not None:
75 if tag_version is not None:
73 version = int(tag_version.text) or 1
76 version = int(tag_version.text) or 1
74 else:
77 else:
75 version = 1
78 version = 1
76 if not exists or global_id.post.version < version:
79 if not exists or global_id.post.version < version:
77 logger.debug('Processed (+) post {}'.format(global_id))
80 logger.debug('Processed (+) post {}'.format(global_id))
78 ids_to_sync.append(global_id)
81 ids_to_sync.append(global_id)
79 else:
82 else:
80 logger.debug('* Processed (-) post {}'.format(global_id))
83 logger.debug('* Processed (-) post {}'.format(global_id))
81 logger.info('Starting sync...')
84 logger.info('Starting sync...')
82
85
83 if len(ids_to_sync) > 0:
86 if len(ids_to_sync) > 0:
84 limit = options.get('split_query', len(ids_to_sync))
87 limit = options.get('split_query', len(ids_to_sync))
85 for offset in range(0, len(ids_to_sync), limit):
88 for offset in range(0, len(ids_to_sync), limit):
86 xml = GlobalId.objects.generate_request_get(ids_to_sync[offset:offset+limit])
89 xml = SyncManager.generate_request_get(ids_to_sync[offset:offset + limit])
87 h = httplib2.Http()
90 h = httplib2.Http()
88 logger.info('Running GET request...')
91 logger.info('Running GET request...')
89 response, content = h.request(get_url, method="POST", body=xml)
92 response, content = h.request(get_url, method="POST", body=xml)
90 logger.info('Processing response...')
93 logger.info('Processing response...')
91
94
92 SyncManager.parse_response_get(content, file_url)
95 SyncManager.parse_response_get(content, file_url)
93 else:
96 else:
94 logger.info('Nothing to get, everything synced')
97 logger.info('Nothing to get, everything synced')
95 else:
98 else:
96 raise Exception('Invalid response status')
99 raise Exception('Invalid response status')
@@ -1,276 +1,282 b''
1 # coding=utf-8
1 # coding=utf-8
2
2
3 import re
3 import re
4 import random
4 import random
5 import bbcode
5 import bbcode
6
6
7 from urllib.parse import unquote
7 from urllib.parse import unquote
8
8
9 from django.core.exceptions import ObjectDoesNotExist
9 from django.core.exceptions import ObjectDoesNotExist
10 from django.core.urlresolvers import reverse
10 from django.core.urlresolvers import reverse
11
11
12 import boards
12 import boards
13 from boards import settings
13
14
14
15
15 __author__ = 'neko259'
16 __author__ = 'neko259'
16
17
17
18
18 REFLINK_PATTERN = re.compile(r'^\d+$')
19 REFLINK_PATTERN = re.compile(r'^\d+$')
19 GLOBAL_REFLINK_PATTERN = re.compile(r'(\w+)::([^:]+)::(\d+)')
20 GLOBAL_REFLINK_PATTERN = re.compile(r'(\w+)::([^:]+)::(\d+)')
20 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
21 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
21 ONE_NEWLINE = '\n'
22 ONE_NEWLINE = '\n'
22 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
23 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
23 LINE_BREAK_HTML = '<div class="br"></div>'
24 LINE_BREAK_HTML = '<div class="br"></div>'
24 SPOILER_SPACE = '&nbsp;'
25 SPOILER_SPACE = '&nbsp;'
25
26
26 MAX_SPOILER_MULTIPLIER = 2
27 MAX_SPOILER_MULTIPLIER = 2
28 MAX_SPOILER_SPACE_COUNT = 20
27
29
28
30
29 class TextFormatter():
31 class TextFormatter():
30 """
32 """
31 An interface for formatter that can be used in the text format panel
33 An interface for formatter that can be used in the text format panel
32 """
34 """
33
35
34 def __init__(self):
36 def __init__(self):
35 pass
37 pass
36
38
37 name = ''
39 name = ''
38
40
39 # Left and right tags for the button preview
41 # Left and right tags for the button preview
40 preview_left = ''
42 preview_left = ''
41 preview_right = ''
43 preview_right = ''
42
44
43 # Left and right characters for the textarea input
45 # Left and right characters for the textarea input
44 format_left = ''
46 format_left = ''
45 format_right = ''
47 format_right = ''
46
48
47
49
48 class AutolinkPattern():
50 class AutolinkPattern():
49 def handleMatch(self, m):
51 def handleMatch(self, m):
50 link_element = etree.Element('a')
52 link_element = etree.Element('a')
51 href = m.group(2)
53 href = m.group(2)
52 link_element.set('href', href)
54 link_element.set('href', href)
53 link_element.text = href
55 link_element.text = href
54
56
55 return link_element
57 return link_element
56
58
57
59
58 class QuotePattern(TextFormatter):
60 class QuotePattern(TextFormatter):
59 name = '>q'
61 name = '>q'
60 preview_left = '<span class="quote">'
62 preview_left = '<span class="quote">'
61 preview_right = '</span>'
63 preview_right = '</span>'
62
64
63 format_left = '[quote]'
65 format_left = '[quote]'
64 format_right = '[/quote]'
66 format_right = '[/quote]'
65
67
66
68
67 class SpoilerPattern(TextFormatter):
69 class SpoilerPattern(TextFormatter):
68 name = 'spoiler'
70 name = 'spoiler'
69 preview_left = '<span class="spoiler">'
71 preview_left = '<span class="spoiler">'
70 preview_right = '</span>'
72 preview_right = '</span>'
71
73
72 format_left = '[spoiler]'
74 format_left = '[spoiler]'
73 format_right = '[/spoiler]'
75 format_right = '[/spoiler]'
74
76
75
77
76 class CommentPattern(TextFormatter):
78 class CommentPattern(TextFormatter):
77 name = ''
79 name = ''
78 preview_left = '<span class="comment">// '
80 preview_left = '<span class="comment">// '
79 preview_right = '</span>'
81 preview_right = '</span>'
80
82
81 format_left = '[comment]'
83 format_left = '[comment]'
82 format_right = '[/comment]'
84 format_right = '[/comment]'
83
85
84
86
85 # TODO Use <s> tag here
87 # TODO Use <s> tag here
86 class StrikeThroughPattern(TextFormatter):
88 class StrikeThroughPattern(TextFormatter):
87 name = 's'
89 name = 's'
88 preview_left = '<span class="strikethrough">'
90 preview_left = '<span class="strikethrough">'
89 preview_right = '</span>'
91 preview_right = '</span>'
90
92
91 format_left = '[s]'
93 format_left = '[s]'
92 format_right = '[/s]'
94 format_right = '[/s]'
93
95
94
96
95 class ItalicPattern(TextFormatter):
97 class ItalicPattern(TextFormatter):
96 name = 'i'
98 name = 'i'
97 preview_left = '<i>'
99 preview_left = '<i>'
98 preview_right = '</i>'
100 preview_right = '</i>'
99
101
100 format_left = '[i]'
102 format_left = '[i]'
101 format_right = '[/i]'
103 format_right = '[/i]'
102
104
103
105
104 class BoldPattern(TextFormatter):
106 class BoldPattern(TextFormatter):
105 name = 'b'
107 name = 'b'
106 preview_left = '<b>'
108 preview_left = '<b>'
107 preview_right = '</b>'
109 preview_right = '</b>'
108
110
109 format_left = '[b]'
111 format_left = '[b]'
110 format_right = '[/b]'
112 format_right = '[/b]'
111
113
112
114
113 class CodePattern(TextFormatter):
115 class CodePattern(TextFormatter):
114 name = 'code'
116 name = 'code'
115 preview_left = '<code>'
117 preview_left = '<code>'
116 preview_right = '</code>'
118 preview_right = '</code>'
117
119
118 format_left = '[code]'
120 format_left = '[code]'
119 format_right = '[/code]'
121 format_right = '[/code]'
120
122
121
123
122 class HintPattern(TextFormatter):
124 class HintPattern(TextFormatter):
123 name = 'hint'
125 name = 'hint'
124 preview_left = '<span class="hint">'
126 preview_left = '<span class="hint">'
125 preview_right = '</span>'
127 preview_right = '</span>'
126
128
127 format_left = '[hint]'
129 format_left = '[hint]'
128 format_right = '[/hint]'
130 format_right = '[/hint]'
129
131
130
132
131 def render_reflink(tag_name, value, options, parent, context):
133 def render_reflink(tag_name, value, options, parent, context):
132 result = '>>%s' % value
134 result = '>>%s' % value
133
135
134 post = None
136 post = None
135 if REFLINK_PATTERN.match(value):
137 if REFLINK_PATTERN.match(value):
136 post_id = int(value)
138 post_id = int(value)
137
139
138 try:
140 try:
139 post = boards.models.Post.objects.get(id=post_id)
141 post = boards.models.Post.objects.get(id=post_id)
140
142
141 except ObjectDoesNotExist:
143 except ObjectDoesNotExist:
142 pass
144 pass
143 elif GLOBAL_REFLINK_PATTERN.match(value):
145 elif GLOBAL_REFLINK_PATTERN.match(value):
144 match = GLOBAL_REFLINK_PATTERN.search(value)
146 match = GLOBAL_REFLINK_PATTERN.search(value)
145 try:
147 try:
146 global_id = boards.models.GlobalId.objects.get(
148 global_id = boards.models.GlobalId.objects.get(
147 key_type=match.group(1), key=match.group(2),
149 key_type=match.group(1), key=match.group(2),
148 local_id=match.group(3))
150 local_id=match.group(3))
149 post = global_id.post
151 post = global_id.post
150 except ObjectDoesNotExist:
152 except ObjectDoesNotExist:
151 pass
153 pass
152
154
153 if post is not None:
155 if post is not None:
154 result = post.get_link_view()
156 result = post.get_link_view()
155
157
156 return result
158 return result
157
159
158
160
159 def render_quote(tag_name, value, options, parent, context):
161 def render_quote(tag_name, value, options, parent, context):
160 source = ''
162 source = ''
161 if 'source' in options:
163 if 'source' in options:
162 source = options['source']
164 source = options['source']
163 elif 'quote' in options:
165 elif 'quote' in options:
164 source = options['quote']
166 source = options['quote']
165
167
166 if source:
168 if source:
167 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
169 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
168 else:
170 else:
169 # Insert a ">" at the start of every line
171 # Insert a ">" at the start of every line
170 result = '<span class="quote">&gt;{}</span>'.format(
172 result = '<span class="quote">&gt;{}</span>'.format(
171 value.replace(LINE_BREAK_HTML,
173 value.replace(LINE_BREAK_HTML,
172 '{}&gt;'.format(LINE_BREAK_HTML)))
174 '{}&gt;'.format(LINE_BREAK_HTML)))
173
175
174 return result
176 return result
175
177
176
178
177 def render_hint(tag_name, value, options, parent, context):
179 def render_hint(tag_name, value, options, parent, context):
178 if 'hint' in options:
180 if 'hint' in options:
179 hint = options['hint']
181 hint = options['hint']
180 result = '<span class="hint" title="{}">{}</span>'.format(hint, value)
182 result = '<span class="hint" title="{}">{}</span>'.format(hint, value)
181 else:
183 else:
182 result = value
184 result = value
183 return result
185 return result
184
186
185
187
186 def render_notification(tag_name, value, options, parent, content):
188 def render_notification(tag_name, value, options, parent, content):
187 username = value.lower()
189 username = value.lower()
188
190
189 return '<a href="{}" class="user-cast">@{}</a>'.format(
191 return '<a href="{}" class="user-cast">@{}</a>'.format(
190 reverse('notifications', kwargs={'username': username}), username)
192 reverse('notifications', kwargs={'username': username}), username)
191
193
192
194
193 def render_tag(tag_name, value, options, parent, context):
195 def render_tag(tag_name, value, options, parent, context):
194 tag_name = value.lower()
196 tag_name = value.lower()
195
197
196 try:
198 try:
197 url = boards.models.Tag.objects.get(name=tag_name).get_view()
199 url = boards.models.Tag.objects.get(name=tag_name).get_view()
198 except ObjectDoesNotExist:
200 except ObjectDoesNotExist:
199 url = tag_name
201 url = tag_name
200
202
201 return url
203 return url
202
204
203
205
204 def render_spoiler(tag_name, value, options, parent, context):
206 def render_spoiler(tag_name, value, options, parent, context):
205 text_len = len(value)
207 if settings.get_bool('Forms', 'AdditionalSpoilerSpaces'):
206 space_count = random.randint(0, text_len * MAX_SPOILER_MULTIPLIER)
208 text_len = len(value)
207 side_spaces = SPOILER_SPACE * (space_count // 2)
209 space_count = min(random.randint(0, text_len * MAX_SPOILER_MULTIPLIER),
208 return '<span class="spoiler">{}{}{}</span>'.format(side_spaces, value,
210 MAX_SPOILER_SPACE_COUNT)
209 side_spaces)
211 side_spaces = SPOILER_SPACE * (space_count // 2)
212 else:
213 side_spaces = ''
214 return '<span class="spoiler">{}{}{}</span>'.format(side_spaces,
215 value, side_spaces)
210
216
211
217
212 formatters = [
218 formatters = [
213 QuotePattern,
219 QuotePattern,
214 SpoilerPattern,
220 SpoilerPattern,
215 ItalicPattern,
221 ItalicPattern,
216 BoldPattern,
222 BoldPattern,
217 CommentPattern,
223 CommentPattern,
218 StrikeThroughPattern,
224 StrikeThroughPattern,
219 CodePattern,
225 CodePattern,
220 HintPattern,
226 HintPattern,
221 ]
227 ]
222
228
223
229
224 PREPARSE_PATTERNS = {
230 PREPARSE_PATTERNS = {
225 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
231 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
226 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
232 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
227 r'^//\s?(.+)': r'[comment]\1[/comment]', # Comment "//text"
233 r'^//\s?(.+)': r'[comment]\1[/comment]', # Comment "//text"
228 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
234 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
229 }
235 }
230
236
231
237
232 class Parser:
238 class Parser:
233 def __init__(self):
239 def __init__(self):
234 # The newline hack is added because br's margin does not work in all
240 # The newline hack is added because br's margin does not work in all
235 # browsers except firefox, when the div's does.
241 # browsers except firefox, when the div's does.
236 self.parser = bbcode.Parser(newline=LINE_BREAK_HTML)
242 self.parser = bbcode.Parser(newline=LINE_BREAK_HTML)
237
243
238 self.parser.add_formatter('post', render_reflink, strip=True)
244 self.parser.add_formatter('post', render_reflink, strip=True)
239 self.parser.add_formatter('quote', render_quote, strip=True)
245 self.parser.add_formatter('quote', render_quote, strip=True)
240 self.parser.add_formatter('hint', render_hint, strip=True)
246 self.parser.add_formatter('hint', render_hint, strip=True)
241 self.parser.add_formatter('user', render_notification, strip=True)
247 self.parser.add_formatter('user', render_notification, strip=True)
242 self.parser.add_formatter('tag', render_tag, strip=True)
248 self.parser.add_formatter('tag', render_tag, strip=True)
243 self.parser.add_formatter('spoiler', render_spoiler, strip=True)
249 self.parser.add_formatter('spoiler', render_spoiler, strip=True)
244 self.parser.add_simple_formatter(
250 self.parser.add_simple_formatter(
245 'comment', '<span class="comment">// %(value)s</span>', strip=True)
251 'comment', '<span class="comment">// %(value)s</span>', strip=True)
246 self.parser.add_simple_formatter(
252 self.parser.add_simple_formatter(
247 's', '<span class="strikethrough">%(value)s</span>')
253 's', '<span class="strikethrough">%(value)s</span>')
248 self.parser.add_simple_formatter('code',
254 self.parser.add_simple_formatter('code',
249 '<pre><code>%(value)s</pre></code>',
255 '<pre><code>%(value)s</pre></code>',
250 render_embedded=False,
256 render_embedded=False,
251 escape_html=True,
257 escape_html=True,
252 replace_links=False,
258 replace_links=False,
253 replace_cosmetic=False)
259 replace_cosmetic=False)
254
260
255 def preparse(self, text):
261 def preparse(self, text):
256 """
262 """
257 Performs manual parsing before the bbcode parser is used.
263 Performs manual parsing before the bbcode parser is used.
258 Preparsed text is saved as raw and the text before preparsing is lost.
264 Preparsed text is saved as raw and the text before preparsing is lost.
259 """
265 """
260 new_text = MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
266 new_text = MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
261
267
262 for key, value in PREPARSE_PATTERNS.items():
268 for key, value in PREPARSE_PATTERNS.items():
263 new_text = re.sub(key, value, new_text, flags=re.MULTILINE)
269 new_text = re.sub(key, value, new_text, flags=re.MULTILINE)
264
270
265 for link in REGEX_URL.findall(text):
271 for link in REGEX_URL.findall(text):
266 new_text = new_text.replace(link, unquote(link))
272 new_text = new_text.replace(link, unquote(link))
267
273
268 return new_text
274 return new_text
269
275
270 def parse(self, text):
276 def parse(self, text):
271 return self.parser.format(text)
277 return self.parser.format(text)
272
278
273
279
274 parser = Parser()
280 parser = Parser()
275 def get_parser():
281 def get_parser():
276 return parser
282 return parser
@@ -1,101 +1,101 b''
1 import re
1 import re
2
2
3 import requests
3 import requests
4 from django.core.files.uploadedfile import TemporaryUploadedFile
4 from django.core.files.uploadedfile import TemporaryUploadedFile
5 from pytube import YouTube
5 from pytube import YouTube
6
6
7 from boards.utils import validate_file_size
7 from boards.utils import validate_file_size
8
8
9 YOUTUBE_VIDEO_FORMAT = 'webm'
9 YOUTUBE_VIDEO_FORMAT = 'webm'
10
10
11 HTTP_RESULT_OK = 200
11 HTTP_RESULT_OK = 200
12
12
13 HEADER_CONTENT_LENGTH = 'content-length'
13 HEADER_CONTENT_LENGTH = 'content-length'
14 HEADER_CONTENT_TYPE = 'content-type'
14 HEADER_CONTENT_TYPE = 'content-type'
15
15
16 FILE_DOWNLOAD_CHUNK_BYTES = 200000
16 FILE_DOWNLOAD_CHUNK_BYTES = 200000
17
17
18 REGEX_YOUTUBE_URL = re.compile(r'https?://((www\.)?youtube\.com/watch\?v=|youtu.be/)[-\w]+')
18 REGEX_YOUTUBE_URL = re.compile(r'https?://((www\.)?youtube\.com/watch\?v=|youtu.be/)[-\w]+')
19 REGEX_MAGNET = re.compile(r'magnet:\?xt=urn:(btih:)?[a-z0-9]{20,50}.*')
19 REGEX_MAGNET = re.compile(r'magnet:\?xt=urn:(btih:)?[a-z0-9]{20,50}.*')
20
20
21 TYPE_URL_ONLY = (
21 TYPE_URL_ONLY = (
22 'application/xhtml+xml',
22 'application/xhtml+xml',
23 'text/html',
23 'text/html',
24 )
24 )
25
25
26
26
27 class Downloader:
27 class Downloader:
28 @staticmethod
28 @staticmethod
29 def handles(url: str) -> bool:
29 def handles(url: str) -> bool:
30 return True
30 return True
31
31
32 @staticmethod
32 @staticmethod
33 def download(url: str):
33 def download(url: str, validate):
34 # Verify content headers
34 # Verify content headers
35 response_head = requests.head(url, verify=False)
35 response_head = requests.head(url, verify=False)
36 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
36 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
37 if content_type in TYPE_URL_ONLY:
37 if validate and content_type in TYPE_URL_ONLY:
38 return None
38 return None
39
39
40 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
40 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
41 if length_header:
41 if validate and length_header:
42 length = int(length_header)
42 length = int(length_header)
43 validate_file_size(length)
43 validate_file_size(length)
44 # Get the actual content into memory
44 # Get the actual content into memory
45 response = requests.get(url, verify=False, stream=True)
45 response = requests.get(url, verify=False, stream=True)
46
46
47 if response.status_code == HTTP_RESULT_OK:
47 if response.status_code == HTTP_RESULT_OK:
48 # Download file, stop if the size exceeds limit
48 # Download file, stop if the size exceeds limit
49 size = 0
49 size = 0
50
50
51 # Set a dummy file name that will be replaced
51 # Set a dummy file name that will be replaced
52 # anyway, just keep the valid extension
52 # anyway, just keep the valid extension
53 filename = 'file.' + content_type.split('/')[1]
53 filename = 'file.' + content_type.split('/')[1]
54
54
55 file = TemporaryUploadedFile(filename, content_type, 0, None, None)
55 file = TemporaryUploadedFile(filename, content_type, 0, None, None)
56 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
56 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
57 size += len(chunk)
57 size += len(chunk)
58 validate_file_size(size)
58 validate_file_size(size)
59 file.write(chunk)
59 file.write(chunk)
60
60
61 return file
61 return file
62
62
63
63
64 class YouTubeDownloader(Downloader):
64 class YouTubeDownloader(Downloader):
65 @staticmethod
65 @staticmethod
66 def download(url: str):
66 def download(url: str, validate):
67 yt = YouTube()
67 yt = YouTube()
68 yt.from_url(url)
68 yt.from_url(url)
69 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
69 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
70 if len(videos) > 0:
70 if len(videos) > 0:
71 video = videos[0]
71 video = videos[0]
72 return Downloader.download(video.url)
72 return Downloader.download(video.url)
73
73
74 @staticmethod
74 @staticmethod
75 def handles(url: str) -> bool:
75 def handles(url: str) -> bool:
76 return REGEX_YOUTUBE_URL.match(url) is not None
76 return REGEX_YOUTUBE_URL.match(url) is not None
77
77
78
78
79 class NothingDownloader(Downloader):
79 class NothingDownloader(Downloader):
80 @staticmethod
80 @staticmethod
81 def handles(url: str) -> bool:
81 def handles(url: str) -> bool:
82 return REGEX_MAGNET.match(url)
82 return REGEX_MAGNET.match(url)
83
83
84 @staticmethod
84 @staticmethod
85 def download(url: str):
85 def download(url: str, validate):
86 return None
86 return None
87
87
88
88
89 DOWNLOADERS = (
89 DOWNLOADERS = (
90 YouTubeDownloader,
90 YouTubeDownloader,
91 NothingDownloader,
91 NothingDownloader,
92 Downloader,
92 Downloader,
93 )
93 )
94
94
95
95
96 def download(url):
96 def download(url, validate=True):
97 for downloader in DOWNLOADERS:
97 for downloader in DOWNLOADERS:
98 if downloader.handles(url):
98 if downloader.handles(url):
99 return downloader.download(url)
99 return downloader.download(url, validate=validate)
100 raise Exception('No downloader supports this URL.')
100 raise Exception('No downloader supports this URL.')
101
101
@@ -1,364 +1,364 b''
1 import uuid
1 import uuid
2 import hashlib
2 import hashlib
3 import re
3 import re
4
4
5 from boards import settings
5 from boards import settings
6 from boards.abstracts.tripcode import Tripcode
6 from boards.abstracts.tripcode import Tripcode
7 from boards.models import Attachment, KeyPair, GlobalId
7 from boards.models import Attachment, KeyPair, GlobalId
8 from boards.models.attachment import FILE_TYPES_IMAGE
8 from boards.models.attachment import FILE_TYPES_IMAGE
9 from boards.models.base import Viewable
9 from boards.models.base import Viewable
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
11 from boards.models.post.manager import PostManager, NO_IP
11 from boards.models.post.manager import PostManager, NO_IP
12 from boards.utils import datetime_to_epoch
12 from boards.utils import datetime_to_epoch
13 from django.core.exceptions import ObjectDoesNotExist
13 from django.core.exceptions import ObjectDoesNotExist
14 from django.core.urlresolvers import reverse
14 from django.core.urlresolvers import reverse
15 from django.db import models
15 from django.db import models
16 from django.db.models import TextField, QuerySet, F
16 from django.db.models import TextField, QuerySet, F
17 from django.template.defaultfilters import truncatewords, striptags
17 from django.template.defaultfilters import truncatewords, striptags
18 from django.template.loader import render_to_string
18 from django.template.loader import render_to_string
19
19
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
21 CSS_CLS_DEAD_POST = 'dead_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 CSS_CLS_POST = 'post'
23 CSS_CLS_POST = 'post'
24 CSS_CLS_MONOCHROME = 'monochrome'
24 CSS_CLS_MONOCHROME = 'monochrome'
25
25
26 TITLE_MAX_WORDS = 10
26 TITLE_MAX_WORDS = 10
27
27
28 APP_LABEL_BOARDS = 'boards'
28 APP_LABEL_BOARDS = 'boards'
29
29
30 BAN_REASON_AUTO = 'Auto'
30 BAN_REASON_AUTO = 'Auto'
31
31
32 TITLE_MAX_LENGTH = 200
32 TITLE_MAX_LENGTH = 200
33
33
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38
38
39 PARAMETER_TRUNCATED = 'truncated'
39 PARAMETER_TRUNCATED = 'truncated'
40 PARAMETER_TAG = 'tag'
40 PARAMETER_TAG = 'tag'
41 PARAMETER_OFFSET = 'offset'
41 PARAMETER_OFFSET = 'offset'
42 PARAMETER_DIFF_TYPE = 'type'
42 PARAMETER_DIFF_TYPE = 'type'
43 PARAMETER_CSS_CLASS = 'css_class'
43 PARAMETER_CSS_CLASS = 'css_class'
44 PARAMETER_THREAD = 'thread'
44 PARAMETER_THREAD = 'thread'
45 PARAMETER_IS_OPENING = 'is_opening'
45 PARAMETER_IS_OPENING = 'is_opening'
46 PARAMETER_POST = 'post'
46 PARAMETER_POST = 'post'
47 PARAMETER_OP_ID = 'opening_post_id'
47 PARAMETER_OP_ID = 'opening_post_id'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
49 PARAMETER_REPLY_LINK = 'reply_link'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
50 PARAMETER_NEED_OP_DATA = 'need_op_data'
51
51
52 POST_VIEW_PARAMS = (
52 POST_VIEW_PARAMS = (
53 'need_op_data',
53 'need_op_data',
54 'reply_link',
54 'reply_link',
55 'need_open_link',
55 'need_open_link',
56 'truncated',
56 'truncated',
57 'mode_tree',
57 'mode_tree',
58 'perms',
58 'perms',
59 'tree_depth',
59 'tree_depth',
60 )
60 )
61
61
62
62
63 class Post(models.Model, Viewable):
63 class Post(models.Model, Viewable):
64 """A post is a message."""
64 """A post is a message."""
65
65
66 objects = PostManager()
66 objects = PostManager()
67
67
68 class Meta:
68 class Meta:
69 app_label = APP_LABEL_BOARDS
69 app_label = APP_LABEL_BOARDS
70 ordering = ('id',)
70 ordering = ('id',)
71
71
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='')
72 title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='')
73 pub_time = models.DateTimeField(db_index=True)
73 pub_time = models.DateTimeField(db_index=True)
74 text = TextField(blank=True, default='')
74 text = TextField(blank=True, default='')
75 _text_rendered = TextField(blank=True, null=True, editable=False)
75 _text_rendered = TextField(blank=True, null=True, editable=False)
76
76
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
78 related_name='attachment_posts')
78 related_name='attachment_posts')
79
79
80 poster_ip = models.GenericIPAddressField()
80 poster_ip = models.GenericIPAddressField()
81
81
82 # Used for cache and threads updating
82 # Used for cache and threads updating
83 last_edit_time = models.DateTimeField()
83 last_edit_time = models.DateTimeField()
84
84
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
86 null=True,
86 null=True,
87 blank=True, related_name='refposts',
87 blank=True, related_name='refposts',
88 db_index=True)
88 db_index=True)
89 refmap = models.TextField(null=True, blank=True)
89 refmap = models.TextField(null=True, blank=True)
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
91
91
92 url = models.TextField()
92 url = models.TextField()
93 uid = models.TextField(db_index=True)
93 uid = models.TextField()
94
94
95 # Global ID with author key. If the message was downloaded from another
95 # Global ID with author key. If the message was downloaded from another
96 # server, this indicates the server.
96 # server, this indicates the server.
97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
98 on_delete=models.CASCADE)
98 on_delete=models.CASCADE)
99
99
100 tripcode = models.CharField(max_length=50, blank=True, default='')
100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 opening = models.BooleanField(db_index=True)
101 opening = models.BooleanField(db_index=True)
102 hidden = models.BooleanField(default=False)
102 hidden = models.BooleanField(default=False)
103 version = models.IntegerField(default=1)
103 version = models.IntegerField(default=1)
104
104
105 def __str__(self):
105 def __str__(self):
106 return 'P#{}/{}'.format(self.id, self.get_title())
106 return 'P#{}/{}'.format(self.id, self.get_title())
107
107
108 def get_title(self) -> str:
108 def get_title(self) -> str:
109 return self.title
109 return self.title
110
110
111 def get_title_or_text(self):
111 def get_title_or_text(self):
112 title = self.get_title()
112 title = self.get_title()
113 if not title:
113 if not title:
114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
115
115
116 return title
116 return title
117
117
118 def build_refmap(self, excluded_ids=None) -> None:
118 def build_refmap(self, excluded_ids=None) -> None:
119 """
119 """
120 Builds a replies map string from replies list. This is a cache to stop
120 Builds a replies map string from replies list. This is a cache to stop
121 the server from recalculating the map on every post show.
121 the server from recalculating the map on every post show.
122 """
122 """
123
123
124 replies = self.referenced_posts
124 replies = self.referenced_posts
125 if excluded_ids is not None:
125 if excluded_ids is not None:
126 replies = replies.exclude(id__in=excluded_ids)
126 replies = replies.exclude(id__in=excluded_ids)
127 else:
127 else:
128 replies = replies.all()
128 replies = replies.all()
129
129
130 post_urls = [refpost.get_link_view() for refpost in replies]
130 post_urls = [refpost.get_link_view() for refpost in replies]
131
131
132 self.refmap = ', '.join(post_urls)
132 self.refmap = ', '.join(post_urls)
133
133
134 def is_referenced(self) -> bool:
134 def is_referenced(self) -> bool:
135 return self.refmap and len(self.refmap) > 0
135 return self.refmap and len(self.refmap) > 0
136
136
137 def is_opening(self) -> bool:
137 def is_opening(self) -> bool:
138 """
138 """
139 Checks if this is an opening post or just a reply.
139 Checks if this is an opening post or just a reply.
140 """
140 """
141
141
142 return self.opening
142 return self.opening
143
143
144 def get_absolute_url(self, thread=None):
144 def get_absolute_url(self, thread=None):
145 # Url is cached only for the "main" thread. When getting url
145 # Url is cached only for the "main" thread. When getting url
146 # for other threads, do it manually.
146 # for other threads, do it manually.
147 return self.url
147 return self.url
148
148
149 def get_thread(self):
149 def get_thread(self):
150 return self.thread
150 return self.thread
151
151
152 def get_thread_id(self):
152 def get_thread_id(self):
153 return self.thread_id
153 return self.thread_id
154
154
155 def _get_cache_key(self):
155 def _get_cache_key(self):
156 return [datetime_to_epoch(self.last_edit_time)]
156 return [datetime_to_epoch(self.last_edit_time)]
157
157
158 def get_view_params(self, *args, **kwargs):
158 def get_view_params(self, *args, **kwargs):
159 """
159 """
160 Gets the parameters required for viewing the post based on the arguments
160 Gets the parameters required for viewing the post based on the arguments
161 given and the post itself.
161 given and the post itself.
162 """
162 """
163 thread = kwargs.get('thread') or self.get_thread()
163 thread = kwargs.get('thread') or self.get_thread()
164
164
165 css_classes = [CSS_CLS_POST]
165 css_classes = [CSS_CLS_POST]
166 if thread.is_archived():
166 if thread.is_archived():
167 css_classes.append(CSS_CLS_ARCHIVE_POST)
167 css_classes.append(CSS_CLS_ARCHIVE_POST)
168 elif not thread.can_bump():
168 elif not thread.can_bump():
169 css_classes.append(CSS_CLS_DEAD_POST)
169 css_classes.append(CSS_CLS_DEAD_POST)
170 if self.is_hidden():
170 if self.is_hidden():
171 css_classes.append(CSS_CLS_HIDDEN_POST)
171 css_classes.append(CSS_CLS_HIDDEN_POST)
172 if thread.is_monochrome():
172 if thread.is_monochrome():
173 css_classes.append(CSS_CLS_MONOCHROME)
173 css_classes.append(CSS_CLS_MONOCHROME)
174
174
175 params = dict()
175 params = dict()
176 for param in POST_VIEW_PARAMS:
176 for param in POST_VIEW_PARAMS:
177 if param in kwargs:
177 if param in kwargs:
178 params[param] = kwargs[param]
178 params[param] = kwargs[param]
179
179
180 params.update({
180 params.update({
181 PARAMETER_POST: self,
181 PARAMETER_POST: self,
182 PARAMETER_IS_OPENING: self.is_opening(),
182 PARAMETER_IS_OPENING: self.is_opening(),
183 PARAMETER_THREAD: thread,
183 PARAMETER_THREAD: thread,
184 PARAMETER_CSS_CLASS: ' '.join(css_classes),
184 PARAMETER_CSS_CLASS: ' '.join(css_classes),
185 })
185 })
186
186
187 return params
187 return params
188
188
189 def get_view(self, *args, **kwargs) -> str:
189 def get_view(self, *args, **kwargs) -> str:
190 """
190 """
191 Renders post's HTML view. Some of the post params can be passed over
191 Renders post's HTML view. Some of the post params can be passed over
192 kwargs for the means of caching (if we view the thread, some params
192 kwargs for the means of caching (if we view the thread, some params
193 are same for every post and don't need to be computed over and over.
193 are same for every post and don't need to be computed over and over.
194 """
194 """
195 params = self.get_view_params(*args, **kwargs)
195 params = self.get_view_params(*args, **kwargs)
196
196
197 return render_to_string('boards/post.html', params)
197 return render_to_string('boards/post.html', params)
198
198
199 def get_images(self) -> Attachment:
199 def get_images(self) -> Attachment:
200 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
200 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
201
201
202 def get_first_image(self) -> Attachment:
202 def get_first_image(self) -> Attachment:
203 try:
203 try:
204 return self.get_images().earliest('-id')
204 return self.get_images().earliest('-id')
205 except Attachment.DoesNotExist:
205 except Attachment.DoesNotExist:
206 return None
206 return None
207
207
208 def set_global_id(self, key_pair=None):
208 def set_global_id(self, key_pair=None):
209 """
209 """
210 Sets global id based on the given key pair. If no key pair is given,
210 Sets global id based on the given key pair. If no key pair is given,
211 default one is used.
211 default one is used.
212 """
212 """
213
213
214 if key_pair:
214 if key_pair:
215 key = key_pair
215 key = key_pair
216 else:
216 else:
217 try:
217 try:
218 key = KeyPair.objects.get(primary=True)
218 key = KeyPair.objects.get(primary=True)
219 except KeyPair.DoesNotExist:
219 except KeyPair.DoesNotExist:
220 # Do not update the global id because there is no key defined
220 # Do not update the global id because there is no key defined
221 return
221 return
222 global_id = GlobalId(key_type=key.key_type,
222 global_id = GlobalId(key_type=key.key_type,
223 key=key.public_key,
223 key=key.public_key,
224 local_id=self.id)
224 local_id=self.id)
225 global_id.save()
225 global_id.save()
226
226
227 self.global_id = global_id
227 self.global_id = global_id
228
228
229 self.save(update_fields=['global_id'])
229 self.save(update_fields=['global_id'])
230
230
231 def get_pub_time_str(self):
231 def get_pub_time_str(self):
232 return str(self.pub_time)
232 return str(self.pub_time)
233
233
234 def get_replied_ids(self):
234 def get_replied_ids(self):
235 """
235 """
236 Gets ID list of the posts that this post replies.
236 Gets ID list of the posts that this post replies.
237 """
237 """
238
238
239 raw_text = self.get_raw_text()
239 raw_text = self.get_raw_text()
240
240
241 local_replied = REGEX_REPLY.findall(raw_text)
241 local_replied = REGEX_REPLY.findall(raw_text)
242 global_replied = []
242 global_replied = []
243 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
243 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
244 key_type = match[0]
244 key_type = match[0]
245 key = match[1]
245 key = match[1]
246 local_id = match[2]
246 local_id = match[2]
247
247
248 try:
248 try:
249 global_id = GlobalId.objects.get(key_type=key_type,
249 global_id = GlobalId.objects.get(key_type=key_type,
250 key=key, local_id=local_id)
250 key=key, local_id=local_id)
251 for post in Post.objects.filter(global_id=global_id).only('id'):
251 for post in Post.objects.filter(global_id=global_id).only('id'):
252 global_replied.append(post.id)
252 global_replied.append(post.id)
253 except GlobalId.DoesNotExist:
253 except GlobalId.DoesNotExist:
254 pass
254 pass
255 return local_replied + global_replied
255 return local_replied + global_replied
256
256
257 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
257 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
258 include_last_update=False) -> str:
258 include_last_update=False) -> str:
259 """
259 """
260 Gets post HTML or JSON data that can be rendered on a page or used by
260 Gets post HTML or JSON data that can be rendered on a page or used by
261 API.
261 API.
262 """
262 """
263
263
264 return get_exporter(format_type).export(self, request,
264 return get_exporter(format_type).export(self, request,
265 include_last_update)
265 include_last_update)
266
266
267 def _build_url(self):
267 def _build_url(self):
268 opening = self.is_opening()
268 opening = self.is_opening()
269 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
269 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
270 url = reverse('thread', kwargs={'post_id': opening_id})
270 url = reverse('thread', kwargs={'post_id': opening_id})
271 if not opening:
271 if not opening:
272 url += '#' + str(self.id)
272 url += '#' + str(self.id)
273
273
274 return url
274 return url
275
275
276 def save(self, force_insert=False, force_update=False, using=None,
276 def save(self, force_insert=False, force_update=False, using=None,
277 update_fields=None):
277 update_fields=None):
278 new_post = self.id is None
278 new_post = self.id is None
279
279
280 self.uid = str(uuid.uuid4())
280 self.uid = str(uuid.uuid4())
281 if update_fields is not None and 'uid' not in update_fields:
281 if update_fields is not None and 'uid' not in update_fields:
282 update_fields += ['uid']
282 update_fields += ['uid']
283
283
284 if not new_post:
284 if not new_post:
285 thread = self.get_thread()
285 thread = self.get_thread()
286 if thread:
286 if thread:
287 thread.last_edit_time = self.last_edit_time
287 thread.last_edit_time = self.last_edit_time
288 thread.save(update_fields=['last_edit_time', 'status'])
288 thread.save(update_fields=['last_edit_time', 'status'])
289
289
290 super().save(force_insert, force_update, using, update_fields)
290 super().save(force_insert, force_update, using, update_fields)
291
291
292 if new_post:
292 if new_post:
293 self.url = self._build_url()
293 self.url = self._build_url()
294 super().save(update_fields=['url'])
294 super().save(update_fields=['url'])
295
295
296 def get_text(self) -> str:
296 def get_text(self) -> str:
297 return self._text_rendered
297 return self._text_rendered
298
298
299 def get_raw_text(self) -> str:
299 def get_raw_text(self) -> str:
300 return self.text
300 return self.text
301
301
302 def get_sync_text(self) -> str:
302 def get_sync_text(self) -> str:
303 """
303 """
304 Returns text applicable for sync. It has absolute post reflinks.
304 Returns text applicable for sync. It has absolute post reflinks.
305 """
305 """
306
306
307 replacements = dict()
307 replacements = dict()
308 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
308 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
309 try:
309 try:
310 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
310 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
311 replacements[post_id] = absolute_post_id
311 replacements[post_id] = absolute_post_id
312 except Post.DoesNotExist:
312 except Post.DoesNotExist:
313 pass
313 pass
314
314
315 text = self.get_raw_text() or ''
315 text = self.get_raw_text() or ''
316 for key in replacements:
316 for key in replacements:
317 text = text.replace('[post]{}[/post]'.format(key),
317 text = text.replace('[post]{}[/post]'.format(key),
318 '[post]{}[/post]'.format(replacements[key]))
318 '[post]{}[/post]'.format(replacements[key]))
319 text = text.replace('\r\n', '\n').replace('\r', '\n')
319 text = text.replace('\r\n', '\n').replace('\r', '\n')
320
320
321 return text
321 return text
322
322
323 def get_tripcode(self):
323 def get_tripcode(self):
324 if self.tripcode:
324 if self.tripcode:
325 return Tripcode(self.tripcode)
325 return Tripcode(self.tripcode)
326
326
327 def get_link_view(self):
327 def get_link_view(self):
328 """
328 """
329 Gets view of a reflink to the post.
329 Gets view of a reflink to the post.
330 """
330 """
331 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
331 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
332 self.id)
332 self.id)
333 if self.is_opening():
333 if self.is_opening():
334 result = '<b>{}</b>'.format(result)
334 result = '<b>{}</b>'.format(result)
335
335
336 return result
336 return result
337
337
338 def is_hidden(self) -> bool:
338 def is_hidden(self) -> bool:
339 return self.hidden
339 return self.hidden
340
340
341 def set_hidden(self, hidden):
341 def set_hidden(self, hidden):
342 self.hidden = hidden
342 self.hidden = hidden
343
343
344 def increment_version(self):
344 def increment_version(self):
345 self.version = F('version') + 1
345 self.version = F('version') + 1
346
346
347 def clear_cache(self):
347 def clear_cache(self):
348 """
348 """
349 Clears sync data (content cache, signatures etc).
349 Clears sync data (content cache, signatures etc).
350 """
350 """
351 global_id = self.global_id
351 global_id = self.global_id
352 if global_id is not None and global_id.is_local()\
352 if global_id is not None and global_id.is_local()\
353 and global_id.content is not None:
353 and global_id.content is not None:
354 global_id.clear_cache()
354 global_id.clear_cache()
355
355
356 def get_tags(self):
356 def get_tags(self):
357 return self.get_thread().get_tags()
357 return self.get_thread().get_tags()
358
358
359 def get_ip_color(self):
359 def get_ip_color(self):
360 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
360 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
361
361
362 def has_ip(self):
362 def has_ip(self):
363 return self.poster_ip != NO_IP
363 return self.poster_ip != NO_IP
364
364
@@ -1,337 +1,345 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2 import logging
2 import logging
3 from xml.etree import ElementTree
3
4
4 from boards.abstracts.exceptions import SyncException
5 from boards.abstracts.exceptions import SyncException
6 from boards.abstracts.sync_filters import ThreadFilter
5 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
7 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
6 from boards.models.attachment.downloaders import download
8 from boards.models.attachment.downloaders import download
9 from boards.models.signature import TAG_REQUEST, ATTR_TYPE, TYPE_GET, \
10 ATTR_VERSION, TAG_MODEL, ATTR_NAME, TAG_ID, TYPE_LIST
7 from boards.utils import get_file_mimetype, get_file_hash
11 from boards.utils import get_file_mimetype, get_file_hash
8 from django.db import transaction
12 from django.db import transaction
9 from django import forms
13 from django import forms
10
14
11 EXCEPTION_NODE = 'Sync node returned an error: {}.'
15 EXCEPTION_NODE = 'Sync node returned an error: {}.'
12 EXCEPTION_DOWNLOAD = 'File was not downloaded.'
16 EXCEPTION_DOWNLOAD = 'File was not downloaded.'
13 EXCEPTION_HASH = 'File hash does not match attachment hash.'
17 EXCEPTION_HASH = 'File hash does not match attachment hash.'
14 EXCEPTION_SIGNATURE = 'Invalid model signature for {}.'
18 EXCEPTION_SIGNATURE = 'Invalid model signature for {}.'
15 EXCEPTION_AUTHOR_SIGNATURE = 'Model {} has no author signature.'
19 EXCEPTION_AUTHOR_SIGNATURE = 'Model {} has no author signature.'
16 ENCODING_UNICODE = 'unicode'
20 ENCODING_UNICODE = 'unicode'
17
21
18 TAG_MODEL = 'model'
22 TAG_MODEL = 'model'
19 TAG_REQUEST = 'request'
23 TAG_REQUEST = 'request'
20 TAG_RESPONSE = 'response'
24 TAG_RESPONSE = 'response'
21 TAG_ID = 'id'
25 TAG_ID = 'id'
22 TAG_STATUS = 'status'
26 TAG_STATUS = 'status'
23 TAG_MODELS = 'models'
27 TAG_MODELS = 'models'
24 TAG_TITLE = 'title'
28 TAG_TITLE = 'title'
25 TAG_TEXT = 'text'
29 TAG_TEXT = 'text'
26 TAG_THREAD = 'thread'
30 TAG_THREAD = 'thread'
27 TAG_PUB_TIME = 'pub-time'
31 TAG_PUB_TIME = 'pub-time'
28 TAG_SIGNATURES = 'signatures'
32 TAG_SIGNATURES = 'signatures'
29 TAG_SIGNATURE = 'signature'
33 TAG_SIGNATURE = 'signature'
30 TAG_CONTENT = 'content'
34 TAG_CONTENT = 'content'
31 TAG_ATTACHMENTS = 'attachments'
35 TAG_ATTACHMENTS = 'attachments'
32 TAG_ATTACHMENT = 'attachment'
36 TAG_ATTACHMENT = 'attachment'
33 TAG_TAGS = 'tags'
37 TAG_TAGS = 'tags'
34 TAG_TAG = 'tag'
38 TAG_TAG = 'tag'
35 TAG_ATTACHMENT_REFS = 'attachment-refs'
39 TAG_ATTACHMENT_REFS = 'attachment-refs'
36 TAG_ATTACHMENT_REF = 'attachment-ref'
40 TAG_ATTACHMENT_REF = 'attachment-ref'
37 TAG_TRIPCODE = 'tripcode'
41 TAG_TRIPCODE = 'tripcode'
38 TAG_VERSION = 'version'
42 TAG_VERSION = 'version'
39
43
40 TYPE_GET = 'get'
44 TYPE_GET = 'get'
41
45
42 ATTR_VERSION = 'version'
46 ATTR_VERSION = 'version'
43 ATTR_TYPE = 'type'
47 ATTR_TYPE = 'type'
44 ATTR_NAME = 'name'
48 ATTR_NAME = 'name'
45 ATTR_VALUE = 'value'
49 ATTR_VALUE = 'value'
46 ATTR_MIMETYPE = 'mimetype'
50 ATTR_MIMETYPE = 'mimetype'
47 ATTR_KEY = 'key'
51 ATTR_KEY = 'key'
48 ATTR_REF = 'ref'
52 ATTR_REF = 'ref'
49 ATTR_URL = 'url'
53 ATTR_URL = 'url'
50 ATTR_ID_TYPE = 'id-type'
54 ATTR_ID_TYPE = 'id-type'
51
55
52 ID_TYPE_MD5 = 'md5'
56 ID_TYPE_MD5 = 'md5'
53 ID_TYPE_URL = 'url'
57 ID_TYPE_URL = 'url'
54
58
55 STATUS_SUCCESS = 'success'
59 STATUS_SUCCESS = 'success'
56
60
57
61
58 logger = logging.getLogger('boards.sync')
62 logger = logging.getLogger('boards.sync')
59
63
60
64
61 class SyncManager:
65 class SyncManager:
62 @staticmethod
66 @staticmethod
63 def generate_response_get(model_list: list):
67 def generate_response_get(model_list: list):
64 response = et.Element(TAG_RESPONSE)
68 response = et.Element(TAG_RESPONSE)
65
69
66 status = et.SubElement(response, TAG_STATUS)
70 status = et.SubElement(response, TAG_STATUS)
67 status.text = STATUS_SUCCESS
71 status.text = STATUS_SUCCESS
68
72
69 models = et.SubElement(response, TAG_MODELS)
73 models = et.SubElement(response, TAG_MODELS)
70
74
71 for post in model_list:
75 for post in model_list:
72 model = et.SubElement(models, TAG_MODEL)
76 model = et.SubElement(models, TAG_MODEL)
73 model.set(ATTR_NAME, 'post')
77 model.set(ATTR_NAME, 'post')
74
78
75 global_id = post.global_id
79 global_id = post.global_id
76
80
77 attachments = post.attachments.all()
81 attachments = post.attachments.all()
78 if global_id.content:
82 if global_id.content:
79 model.append(et.fromstring(global_id.content))
83 model.append(et.fromstring(global_id.content))
80 if len(attachments) > 0:
84 if len(attachments) > 0:
81 internal_attachments = False
85 internal_attachments = False
82 for attachment in attachments:
86 for attachment in attachments:
83 if attachment.is_internal():
87 if attachment.is_internal():
84 internal_attachments = True
88 internal_attachments = True
85 break
89 break
86
90
87 if internal_attachments:
91 if internal_attachments:
88 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
92 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
89 for file in attachments:
93 for file in attachments:
90 SyncManager._attachment_to_xml(
94 SyncManager._attachment_to_xml(
91 None, attachment_refs, file)
95 None, attachment_refs, file)
92 else:
96 else:
93 content_tag = et.SubElement(model, TAG_CONTENT)
97 content_tag = et.SubElement(model, TAG_CONTENT)
94
98
95 tag_id = et.SubElement(content_tag, TAG_ID)
99 tag_id = et.SubElement(content_tag, TAG_ID)
96 global_id.to_xml_element(tag_id)
100 global_id.to_xml_element(tag_id)
97
101
98 title = et.SubElement(content_tag, TAG_TITLE)
102 title = et.SubElement(content_tag, TAG_TITLE)
99 title.text = post.title
103 title.text = post.title
100
104
101 text = et.SubElement(content_tag, TAG_TEXT)
105 text = et.SubElement(content_tag, TAG_TEXT)
102 text.text = post.get_sync_text()
106 text.text = post.get_sync_text()
103
107
104 thread = post.get_thread()
108 thread = post.get_thread()
105 if post.is_opening():
109 if post.is_opening():
106 tag_tags = et.SubElement(content_tag, TAG_TAGS)
110 tag_tags = et.SubElement(content_tag, TAG_TAGS)
107 for tag in thread.get_tags():
111 for tag in thread.get_tags():
108 tag_tag = et.SubElement(tag_tags, TAG_TAG)
112 tag_tag = et.SubElement(tag_tags, TAG_TAG)
109 tag_tag.text = tag.name
113 tag_tag.text = tag.name
110 else:
114 else:
111 tag_thread = et.SubElement(content_tag, TAG_THREAD)
115 tag_thread = et.SubElement(content_tag, TAG_THREAD)
112 thread_id = et.SubElement(tag_thread, TAG_ID)
116 thread_id = et.SubElement(tag_thread, TAG_ID)
113 thread.get_opening_post().global_id.to_xml_element(thread_id)
117 thread.get_opening_post().global_id.to_xml_element(thread_id)
114
118
115 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
119 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
116 pub_time.text = str(post.get_pub_time_str())
120 pub_time.text = str(post.get_pub_time_str())
117
121
118 if post.tripcode:
122 if post.tripcode:
119 tripcode = et.SubElement(content_tag, TAG_TRIPCODE)
123 tripcode = et.SubElement(content_tag, TAG_TRIPCODE)
120 tripcode.text = post.tripcode
124 tripcode.text = post.tripcode
121
125
122 if len(attachments) > 0:
126 if len(attachments) > 0:
123 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
127 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
124
128
125 internal_attachments = False
129 internal_attachments = False
126 for attachment in attachments:
130 for attachment in attachments:
127 if attachment.is_internal():
131 if attachment.is_internal():
128 internal_attachments = True
132 internal_attachments = True
129 break
133 break
130
134
131 if internal_attachments:
135 if internal_attachments:
132 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
136 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
133 else:
137 else:
134 attachment_refs = None
138 attachment_refs = None
135
139
136 for file in attachments:
140 for file in attachments:
137 SyncManager._attachment_to_xml(
141 SyncManager._attachment_to_xml(
138 attachments_tag, attachment_refs, file)
142 attachments_tag, attachment_refs, file)
139 version_tag = et.SubElement(content_tag, TAG_VERSION)
143 version_tag = et.SubElement(content_tag, TAG_VERSION)
140 version_tag.text = str(post.version)
144 version_tag.text = str(post.version)
141
145
142 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
146 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
143 global_id.save()
147 global_id.save()
144
148
145 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
149 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
146 post_signatures = global_id.signature_set.all()
150 post_signatures = global_id.signature_set.all()
147 if post_signatures:
151 if post_signatures:
148 signatures = post_signatures
152 signatures = post_signatures
149 else:
153 else:
150 key = KeyPair.objects.get(public_key=global_id.key)
154 key = KeyPair.objects.get(public_key=global_id.key)
151 signature = Signature(
155 signature = Signature(
152 key_type=key.key_type,
156 key_type=key.key_type,
153 key=key.public_key,
157 key=key.public_key,
154 signature=key.sign(global_id.content),
158 signature=key.sign(global_id.content),
155 global_id=global_id,
159 global_id=global_id,
156 )
160 )
157 signature.save()
161 signature.save()
158 signatures = [signature]
162 signatures = [signature]
159 for signature in signatures:
163 for signature in signatures:
160 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
164 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
161 signature_tag.set(ATTR_TYPE, signature.key_type)
165 signature_tag.set(ATTR_TYPE, signature.key_type)
162 signature_tag.set(ATTR_VALUE, signature.signature)
166 signature_tag.set(ATTR_VALUE, signature.signature)
163 signature_tag.set(ATTR_KEY, signature.key)
167 signature_tag.set(ATTR_KEY, signature.key)
164
168
165 return et.tostring(response, ENCODING_UNICODE)
169 return et.tostring(response, ENCODING_UNICODE)
166
170
167 @staticmethod
171 @staticmethod
168 def parse_response_get(response_xml, hostname):
172 def parse_response_get(response_xml, hostname):
169 tag_root = et.fromstring(response_xml)
173 tag_root = et.fromstring(response_xml)
170 tag_status = tag_root.find(TAG_STATUS)
174 tag_status = tag_root.find(TAG_STATUS)
171 if STATUS_SUCCESS == tag_status.text:
175 if STATUS_SUCCESS == tag_status.text:
172 tag_models = tag_root.find(TAG_MODELS)
176 tag_models = tag_root.find(TAG_MODELS)
173 for tag_model in tag_models:
177 for tag_model in tag_models:
174 SyncManager.parse_post(tag_model, hostname)
178 SyncManager.parse_post(tag_model, hostname)
175 else:
179 else:
176 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
180 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
177
181
178 @staticmethod
182 @staticmethod
179 @transaction.atomic
183 @transaction.atomic
180 def parse_post(tag_model, hostname):
184 def parse_post(tag_model, hostname):
181 tag_content = tag_model.find(TAG_CONTENT)
185 tag_content = tag_model.find(TAG_CONTENT)
182
186
183 content_str = et.tostring(tag_content, ENCODING_UNICODE)
187 content_str = et.tostring(tag_content, ENCODING_UNICODE)
184
188
185 tag_id = tag_content.find(TAG_ID)
189 tag_id = tag_content.find(TAG_ID)
186 global_id, exists = GlobalId.from_xml_element(tag_id)
190 global_id, exists = GlobalId.from_xml_element(tag_id)
187 signatures = SyncManager._verify_model(global_id, content_str, tag_model)
191 signatures = SyncManager._verify_model(global_id, content_str, tag_model)
188
192
189 version = int(tag_content.find(TAG_VERSION).text)
193 version = int(tag_content.find(TAG_VERSION).text)
190 is_old = exists and global_id.post.version < version
194 is_old = exists and global_id.post.version < version
191 if exists and not is_old:
195 if exists and not is_old:
192 print('Post with same ID exists and is up to date.')
196 print('Post with same ID exists and is up to date.')
193 else:
197 else:
194 global_id.content = content_str
198 global_id.content = content_str
195 global_id.save()
199 global_id.save()
196 for signature in signatures:
200 for signature in signatures:
197 signature.global_id = global_id
201 signature.global_id = global_id
198 signature.save()
202 signature.save()
199
203
200 title = tag_content.find(TAG_TITLE).text or ''
204 title = tag_content.find(TAG_TITLE).text or ''
201 text = tag_content.find(TAG_TEXT).text or ''
205 text = tag_content.find(TAG_TEXT).text or ''
202 pub_time = tag_content.find(TAG_PUB_TIME).text
206 pub_time = tag_content.find(TAG_PUB_TIME).text
203 tripcode_tag = tag_content.find(TAG_TRIPCODE)
207 tripcode_tag = tag_content.find(TAG_TRIPCODE)
204 if tripcode_tag is not None:
208 if tripcode_tag is not None:
205 tripcode = tripcode_tag.text or ''
209 tripcode = tripcode_tag.text or ''
206 else:
210 else:
207 tripcode = ''
211 tripcode = ''
208
212
209 thread = tag_content.find(TAG_THREAD)
213 thread = tag_content.find(TAG_THREAD)
210 tags = []
214 tags = []
211 if thread:
215 if thread:
212 thread_id = thread.find(TAG_ID)
216 thread_id = thread.find(TAG_ID)
213 op_global_id, exists = GlobalId.from_xml_element(thread_id)
217 op_global_id, exists = GlobalId.from_xml_element(thread_id)
214 if exists:
218 if exists:
215 opening_post = Post.objects.get(global_id=op_global_id)
219 opening_post = Post.objects.get(global_id=op_global_id)
216 else:
220 else:
217 logger.debug('No thread exists for post {}'.format(global_id))
221 logger.debug('No thread exists for post {}'.format(global_id))
218 else:
222 else:
219 opening_post = None
223 opening_post = None
220 tag_tags = tag_content.find(TAG_TAGS)
224 tag_tags = tag_content.find(TAG_TAGS)
221 for tag_tag in tag_tags:
225 for tag_tag in tag_tags:
222 tag, created = Tag.objects.get_or_create(
226 tag, created = Tag.objects.get_or_create(
223 name=tag_tag.text)
227 name=tag_tag.text)
224 tags.append(tag)
228 tags.append(tag)
225
229
226 # TODO Check that the replied posts are already present
230 # TODO Check that the replied posts are already present
227 # before adding new ones
231 # before adding new ones
228
232
229 files = []
233 files = []
230 urls = []
234 urls = []
231 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
235 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
232 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
236 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
233 for attachment in tag_attachments:
237 for attachment in tag_attachments:
234 if attachment.get(ATTR_ID_TYPE) == ID_TYPE_URL:
238 if attachment.get(ATTR_ID_TYPE) == ID_TYPE_URL:
235 urls.append(attachment.text)
239 urls.append(attachment.text)
236 else:
240 else:
237 tag_ref = tag_refs.find("{}[@ref='{}']".format(
241 tag_ref = tag_refs.find("{}[@ref='{}']".format(
238 TAG_ATTACHMENT_REF, attachment.text))
242 TAG_ATTACHMENT_REF, attachment.text))
239 url = tag_ref.get(ATTR_URL)
243 url = tag_ref.get(ATTR_URL)
240 try:
244 try:
241 attached_file = download(hostname + url)
245 attached_file = download(hostname + url, validate=False)
242
246
243 if attached_file is None:
247 if attached_file is None:
244 raise SyncException(EXCEPTION_DOWNLOAD)
248 raise SyncException(EXCEPTION_DOWNLOAD)
245
249
246 hash = get_file_hash(attached_file)
250 hash = get_file_hash(attached_file)
247 if hash != attachment.text:
251 if hash != attachment.text:
248 raise SyncException(EXCEPTION_HASH)
252 raise SyncException(EXCEPTION_HASH)
249
253
250 files.append(attached_file)
254 files.append(attached_file)
251 except forms.ValidationError:
255 except forms.ValidationError:
252 urls.append(hostname+url)
256 urls.append(hostname+url)
253
257
254
258
255 if is_old:
259 if is_old:
256 post = global_id.post
260 post = global_id.post
257 Post.objects.update_post(
261 Post.objects.update_post(
258 post, title=title, text=text, pub_time=pub_time,
262 post, title=title, text=text, pub_time=pub_time,
259 tags=tags, files=files, file_urls=urls,
263 tags=tags, files=files, file_urls=urls,
260 tripcode=tripcode, version=version)
264 tripcode=tripcode, version=version)
261 logger.debug('Parsed updated post {}'.format(global_id))
265 logger.debug('Parsed updated post {}'.format(global_id))
262 else:
266 else:
263 Post.objects.import_post(
267 Post.objects.import_post(
264 title=title, text=text, pub_time=pub_time,
268 title=title, text=text, pub_time=pub_time,
265 opening_post=opening_post, tags=tags,
269 opening_post=opening_post, tags=tags,
266 global_id=global_id, files=files,
270 global_id=global_id, files=files,
267 file_urls=urls, tripcode=tripcode,
271 file_urls=urls, tripcode=tripcode,
268 version=version)
272 version=version)
269 logger.debug('Parsed new post {}'.format(global_id))
273 logger.debug('Parsed new post {}'.format(global_id))
270
274
271 @staticmethod
275 @staticmethod
272 def generate_response_list():
276 def generate_response_list(filters):
273 response = et.Element(TAG_RESPONSE)
277 response = et.Element(TAG_RESPONSE)
274
278
275 status = et.SubElement(response, TAG_STATUS)
279 status = et.SubElement(response, TAG_STATUS)
276 status.text = STATUS_SUCCESS
280 status.text = STATUS_SUCCESS
277
281
278 models = et.SubElement(response, TAG_MODELS)
282 models = et.SubElement(response, TAG_MODELS)
279
283
280 for post in Post.objects.prefetch_related('global_id').all():
284 posts = Post.objects.prefetch_related('global_id')
285 for post_filter in filters:
286 posts = post_filter.filter(posts)
287
288 for post in posts:
281 tag_model = et.SubElement(models, TAG_MODEL)
289 tag_model = et.SubElement(models, TAG_MODEL)
282 tag_id = et.SubElement(tag_model, TAG_ID)
290 tag_id = et.SubElement(tag_model, TAG_ID)
283 post.global_id.to_xml_element(tag_id)
291 post.global_id.to_xml_element(tag_id)
284 tag_version = et.SubElement(tag_model, TAG_VERSION)
292 tag_version = et.SubElement(tag_model, TAG_VERSION)
285 tag_version.text = str(post.version)
293 tag_version.text = str(post.version)
286
294
287 return et.tostring(response, ENCODING_UNICODE)
295 return et.tostring(response, ENCODING_UNICODE)
288
296
289 @staticmethod
297 @staticmethod
290 def _verify_model(global_id, content_str, tag_model):
298 def _verify_model(global_id, content_str, tag_model):
291 """
299 """
292 Verifies all signatures for a single model.
300 Verifies all signatures for a single model.
293 """
301 """
294
302
295 signatures = []
303 signatures = []
296
304
297 tag_signatures = tag_model.find(TAG_SIGNATURES)
305 tag_signatures = tag_model.find(TAG_SIGNATURES)
298 has_author_signature = False
306 has_author_signature = False
299 for tag_signature in tag_signatures:
307 for tag_signature in tag_signatures:
300 signature_type = tag_signature.get(ATTR_TYPE)
308 signature_type = tag_signature.get(ATTR_TYPE)
301 signature_value = tag_signature.get(ATTR_VALUE)
309 signature_value = tag_signature.get(ATTR_VALUE)
302 signature_key = tag_signature.get(ATTR_KEY)
310 signature_key = tag_signature.get(ATTR_KEY)
303
311
304 if global_id.key_type == signature_type and\
312 if global_id.key_type == signature_type and\
305 global_id.key == signature_key:
313 global_id.key == signature_key:
306 has_author_signature = True
314 has_author_signature = True
307
315
308 signature = Signature(key_type=signature_type,
316 signature = Signature(key_type=signature_type,
309 key=signature_key,
317 key=signature_key,
310 signature=signature_value)
318 signature=signature_value)
311
319
312 if not KeyPair.objects.verify(signature, content_str):
320 if not KeyPair.objects.verify(signature, content_str):
313 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
321 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
314
322
315 signatures.append(signature)
323 signatures.append(signature)
316 if not has_author_signature:
324 if not has_author_signature:
317 raise SyncException(EXCEPTION_AUTHOR_SIGNATURE.format(content_str))
325 raise SyncException(EXCEPTION_AUTHOR_SIGNATURE.format(content_str))
318
326
319 return signatures
327 return signatures
320
328
321 @staticmethod
329 @staticmethod
322 def _attachment_to_xml(tag_attachments, tag_refs, attachment):
330 def _attachment_to_xml(tag_attachments, tag_refs, attachment):
323 if tag_attachments is not None:
331 if tag_attachments is not None:
324 attachment_tag = et.SubElement(tag_attachments, TAG_ATTACHMENT)
332 attachment_tag = et.SubElement(tag_attachments, TAG_ATTACHMENT)
325 if attachment.is_internal():
333 if attachment.is_internal():
326 mimetype = get_file_mimetype(attachment.file.file)
334 mimetype = get_file_mimetype(attachment.file.file)
327 attachment_tag.set(ATTR_MIMETYPE, mimetype)
335 attachment_tag.set(ATTR_MIMETYPE, mimetype)
328 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_MD5)
336 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_MD5)
329 attachment_tag.text = attachment.hash
337 attachment_tag.text = attachment.hash
330 else:
338 else:
331 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_URL)
339 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_URL)
332 attachment_tag.text = attachment.url
340 attachment_tag.text = attachment.url
333
341
334 if tag_refs is not None:
342 if tag_refs is not None:
335 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
343 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
336 attachment_ref.set(ATTR_REF, attachment.hash)
344 attachment_ref.set(ATTR_REF, attachment.hash)
337 attachment_ref.set(ATTR_URL, attachment.file.url)
345 attachment_ref.set(ATTR_URL, attachment.file.url)
@@ -1,158 +1,125 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2
2 from django.db import models
3 from django.db import models
4
3 from boards.models import KeyPair
5 from boards.models import KeyPair
4
6
5
6 TAG_MODEL = 'model'
7 TAG_MODEL = 'model'
7 TAG_REQUEST = 'request'
8 TAG_REQUEST = 'request'
8 TAG_ID = 'id'
9 TAG_ID = 'id'
9
10
10 TYPE_GET = 'get'
11 TYPE_GET = 'get'
11 TYPE_LIST = 'list'
12 TYPE_LIST = 'list'
12
13
13 ATTR_VERSION = 'version'
14 ATTR_VERSION = 'version'
14 ATTR_TYPE = 'type'
15 ATTR_TYPE = 'type'
15 ATTR_NAME = 'name'
16 ATTR_NAME = 'name'
16
17
17 ATTR_KEY = 'key'
18 ATTR_KEY = 'key'
18 ATTR_KEY_TYPE = 'type'
19 ATTR_KEY_TYPE = 'type'
19 ATTR_LOCAL_ID = 'local-id'
20 ATTR_LOCAL_ID = 'local-id'
20
21
21
22
22 class GlobalIdManager(models.Manager):
23 class GlobalIdManager(models.Manager):
23 def generate_request_get(self, global_id_list: list):
24 """
25 Form a get request from a list of ModelId objects.
26 """
27
28 request = et.Element(TAG_REQUEST)
29 request.set(ATTR_TYPE, TYPE_GET)
30 request.set(ATTR_VERSION, '1.0')
31
32 model = et.SubElement(request, TAG_MODEL)
33 model.set(ATTR_VERSION, '1.0')
34 model.set(ATTR_NAME, 'post')
35
36 for global_id in global_id_list:
37 tag_id = et.SubElement(model, TAG_ID)
38 global_id.to_xml_element(tag_id)
39
40 return et.tostring(request, 'unicode')
41
42 def generate_request_list(self):
43 """
44 Form a pull request from a list of ModelId objects.
45 """
46
47 request = et.Element(TAG_REQUEST)
48 request.set(ATTR_TYPE, TYPE_LIST)
49 request.set(ATTR_VERSION, '1.0')
50
51 model = et.SubElement(request, TAG_MODEL)
52 model.set(ATTR_VERSION, '1.0')
53 model.set(ATTR_NAME, 'post')
54
55 return et.tostring(request, 'unicode')
56
57 def global_id_exists(self, global_id):
24 def global_id_exists(self, global_id):
58 """
25 """
59 Checks if the same global id already exists in the system.
26 Checks if the same global id already exists in the system.
60 """
27 """
61
28
62 return self.filter(key=global_id.key,
29 return self.filter(key=global_id.key,
63 key_type=global_id.key_type,
30 key_type=global_id.key_type,
64 local_id=global_id.local_id).exists()
31 local_id=global_id.local_id).exists()
65
32
66
33
67 class GlobalId(models.Model):
34 class GlobalId(models.Model):
68 """
35 """
69 Global model ID and cache.
36 Global model ID and cache.
70 Key, key type and local ID make a single global identificator of the model.
37 Key, key type and local ID make a single global identificator of the model.
71 Content is an XML cache of the model that can be passed along between nodes
38 Content is an XML cache of the model that can be passed along between nodes
72 without manual serialization each time.
39 without manual serialization each time.
73 """
40 """
74 class Meta:
41 class Meta:
75 app_label = 'boards'
42 app_label = 'boards'
76
43
77 objects = GlobalIdManager()
44 objects = GlobalIdManager()
78
45
79 def __init__(self, *args, **kwargs):
46 def __init__(self, *args, **kwargs):
80 models.Model.__init__(self, *args, **kwargs)
47 models.Model.__init__(self, *args, **kwargs)
81
48
82 if 'key' in kwargs and 'key_type' in kwargs and 'local_id' in kwargs:
49 if 'key' in kwargs and 'key_type' in kwargs and 'local_id' in kwargs:
83 self.key = kwargs['key']
50 self.key = kwargs['key']
84 self.key_type = kwargs['key_type']
51 self.key_type = kwargs['key_type']
85 self.local_id = kwargs['local_id']
52 self.local_id = kwargs['local_id']
86
53
87 key = models.TextField()
54 key = models.TextField()
88 key_type = models.TextField()
55 key_type = models.TextField()
89 local_id = models.IntegerField()
56 local_id = models.IntegerField()
90 content = models.TextField(blank=True, null=True)
57 content = models.TextField(blank=True, null=True)
91
58
92 def __str__(self):
59 def __str__(self):
93 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)
94
61
95 def to_xml_element(self, element: et.Element):
62 def to_xml_element(self, element: et.Element):
96 """
63 """
97 Exports global id to an XML element.
64 Exports global id to an XML element.
98 """
65 """
99
66
100 element.set(ATTR_KEY, self.key)
67 element.set(ATTR_KEY, self.key)
101 element.set(ATTR_KEY_TYPE, self.key_type)
68 element.set(ATTR_KEY_TYPE, self.key_type)
102 element.set(ATTR_LOCAL_ID, str(self.local_id))
69 element.set(ATTR_LOCAL_ID, str(self.local_id))
103
70
104 @staticmethod
71 @staticmethod
105 def from_xml_element(element: et.Element):
72 def from_xml_element(element: et.Element):
106 """
73 """
107 Parses XML id tag and gets global id from it.
74 Parses XML id tag and gets global id from it.
108
75
109 Arguments:
76 Arguments:
110 element -- the XML 'id' element
77 element -- the XML 'id' element
111
78
112 Returns:
79 Returns:
113 global_id -- id itself
80 global_id -- id itself
114 exists -- True if the global id was taken from database, False if it
81 exists -- True if the global id was taken from database, False if it
115 did not exist and was created.
82 did not exist and was created.
116 """
83 """
117
84
118 try:
85 try:
119 return GlobalId.objects.get(key=element.get(ATTR_KEY),
86 return GlobalId.objects.get(key=element.get(ATTR_KEY),
120 key_type=element.get(ATTR_KEY_TYPE),
87 key_type=element.get(ATTR_KEY_TYPE),
121 local_id=int(element.get(
88 local_id=int(element.get(
122 ATTR_LOCAL_ID))), True
89 ATTR_LOCAL_ID))), True
123 except GlobalId.DoesNotExist:
90 except GlobalId.DoesNotExist:
124 return GlobalId(key=element.get(ATTR_KEY),
91 return GlobalId(key=element.get(ATTR_KEY),
125 key_type=element.get(ATTR_KEY_TYPE),
92 key_type=element.get(ATTR_KEY_TYPE),
126 local_id=int(element.get(ATTR_LOCAL_ID))), False
93 local_id=int(element.get(ATTR_LOCAL_ID))), False
127
94
128 def is_local(self):
95 def is_local(self):
129 """Checks fo the ID is local model's"""
96 """Checks fo the ID is local model's"""
130 return KeyPair.objects.filter(
97 return KeyPair.objects.filter(
131 key_type=self.key_type, public_key=self.key).exists()
98 key_type=self.key_type, public_key=self.key).exists()
132
99
133 def clear_cache(self):
100 def clear_cache(self):
134 """
101 """
135 Removes content cache and signatures.
102 Removes content cache and signatures.
136 """
103 """
137 self.content = None
104 self.content = None
138 self.save()
105 self.save()
139 self.signature_set.all().delete()
106 self.signature_set.all().delete()
140
107
141
108
142 class Signature(models.Model):
109 class Signature(models.Model):
143 class Meta:
110 class Meta:
144 app_label = 'boards'
111 app_label = 'boards'
145
112
146 def __init__(self, *args, **kwargs):
113 def __init__(self, *args, **kwargs):
147 models.Model.__init__(self, *args, **kwargs)
114 models.Model.__init__(self, *args, **kwargs)
148
115
149 if 'key' in kwargs and 'key_type' in kwargs and 'signature' in kwargs:
116 if 'key' in kwargs and 'key_type' in kwargs and 'signature' in kwargs:
150 self.key_type = kwargs['key_type']
117 self.key_type = kwargs['key_type']
151 self.key = kwargs['key']
118 self.key = kwargs['key']
152 self.signature = kwargs['signature']
119 self.signature = kwargs['signature']
153
120
154 key_type = models.TextField()
121 key_type = models.TextField()
155 key = models.TextField()
122 key = models.TextField()
156 signature = models.TextField()
123 signature = models.TextField()
157
124
158 global_id = models.ForeignKey('GlobalId')
125 global_id = models.ForeignKey('GlobalId')
@@ -1,132 +1,135 b''
1 import re
1 import re
2 import os
2 import os
3
3
4 from django.db.models.signals import post_save, pre_save, pre_delete, \
4 from django.db.models.signals import post_save, pre_save, pre_delete, \
5 post_delete
5 post_delete
6 from django.dispatch import receiver
6 from django.dispatch import receiver
7 from django.utils import timezone
7 from django.utils import timezone
8
8
9 from boards import thumbs
9 from boards import thumbs
10 from boards.mdx_neboard import get_parser
10 from boards.mdx_neboard import get_parser
11
11
12 from boards.models import Post, GlobalId, Attachment
12 from boards.models import Post, GlobalId, Attachment
13 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
13 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
14 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
14 from boards.models.post import REGEX_NOTIFICATION, REGEX_REPLY,\
15 REGEX_GLOBAL_REPLY
15 REGEX_GLOBAL_REPLY
16 from boards.models.post.manager import post_import_deps
16 from boards.models.post.manager import post_import_deps
17 from boards.models.user import Notification
17 from boards.models.user import Notification
18 from neboard.settings import MEDIA_ROOT
18 from neboard.settings import MEDIA_ROOT
19
19
20
20
21 THUMB_SIZES = ((200, 150),)
21 THUMB_SIZES = ((200, 150),)
22
22
23
23
24 @receiver(post_save, sender=Post)
24 @receiver(post_save, sender=Post)
25 def connect_replies(instance, **kwargs):
25 def connect_replies(instance, **kwargs):
26 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
26 if not kwargs['update_fields']:
27 post_id = reply_number.group(1)
27 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
28 post_id = reply_number.group(1)
28
29
29 try:
30 try:
30 referenced_post = Post.objects.get(id=post_id)
31 referenced_post = Post.objects.get(id=post_id)
31
32
32 if not referenced_post.referenced_posts.filter(
33 if not referenced_post.referenced_posts.filter(
33 id=instance.id).exists():
34 id=instance.id).exists():
34 referenced_post.referenced_posts.add(instance)
35 referenced_post.referenced_posts.add(instance)
35 referenced_post.last_edit_time = instance.pub_time
36 referenced_post.last_edit_time = instance.pub_time
36 referenced_post.build_refmap()
37 referenced_post.build_refmap()
37 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
38 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
38 except Post.DoesNotExist:
39 except Post.DoesNotExist:
39 pass
40 pass
40
41
41
42
42 @receiver(post_save, sender=Post)
43 @receiver(post_save, sender=Post)
43 @receiver(post_import_deps, sender=Post)
44 @receiver(post_import_deps, sender=Post)
44 def connect_global_replies(instance, **kwargs):
45 def connect_global_replies(instance, **kwargs):
45 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
46 if not kwargs['update_fields']:
46 key_type = reply_number.group(1)
47 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
47 key = reply_number.group(2)
48 key_type = reply_number.group(1)
48 local_id = reply_number.group(3)
49 key = reply_number.group(2)
50 local_id = reply_number.group(3)
49
51
50 try:
52 try:
51 global_id = GlobalId.objects.get(key_type=key_type, key=key,
53 global_id = GlobalId.objects.get(key_type=key_type, key=key,
52 local_id=local_id)
54 local_id=local_id)
53 referenced_post = Post.objects.get(global_id=global_id)
55 referenced_post = Post.objects.get(global_id=global_id)
54 referenced_post.referenced_posts.add(instance)
56 referenced_post.referenced_posts.add(instance)
55 referenced_post.last_edit_time = instance.pub_time
57 referenced_post.last_edit_time = instance.pub_time
56 referenced_post.build_refmap()
58 referenced_post.build_refmap()
57 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
59 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
58 except (GlobalId.DoesNotExist, Post.DoesNotExist):
60 except (GlobalId.DoesNotExist, Post.DoesNotExist):
59 pass
61 pass
60
62
61
63
62 @receiver(post_save, sender=Post)
64 @receiver(post_save, sender=Post)
63 def connect_notifications(instance, **kwargs):
65 def connect_notifications(instance, **kwargs):
64 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
66 if not kwargs['update_fields']:
65 user_name = reply_number.group(1).lower()
67 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
66 Notification.objects.get_or_create(name=user_name, post=instance)
68 user_name = reply_number.group(1).lower()
69 Notification.objects.get_or_create(name=user_name, post=instance)
67
70
68
71
69 @receiver(pre_save, sender=Post)
72 @receiver(pre_save, sender=Post)
70 @receiver(post_import_deps, sender=Post)
73 @receiver(post_import_deps, sender=Post)
71 def parse_text(instance, **kwargs):
74 def parse_text(instance, **kwargs):
72 instance._text_rendered = get_parser().parse(instance.get_raw_text())
75 instance._text_rendered = get_parser().parse(instance.get_raw_text())
73
76
74
77
75 @receiver(pre_delete, sender=Post)
78 @receiver(pre_delete, sender=Post)
76 def delete_attachments(instance, **kwargs):
79 def delete_attachments(instance, **kwargs):
77 for attachment in instance.attachments.all():
80 for attachment in instance.attachments.all():
78 attachment_refs_count = attachment.attachment_posts.count()
81 attachment_refs_count = attachment.attachment_posts.count()
79 if attachment_refs_count == 1:
82 if attachment_refs_count == 1:
80 attachment.delete()
83 attachment.delete()
81
84
82
85
83 @receiver(post_delete, sender=Post)
86 @receiver(post_delete, sender=Post)
84 def update_thread_on_delete(instance, **kwargs):
87 def update_thread_on_delete(instance, **kwargs):
85 thread = instance.get_thread()
88 thread = instance.get_thread()
86 thread.last_edit_time = timezone.now()
89 thread.last_edit_time = timezone.now()
87 thread.save()
90 thread.save()
88
91
89
92
90 @receiver(post_delete, sender=Post)
93 @receiver(post_delete, sender=Post)
91 def delete_global_id(instance, **kwargs):
94 def delete_global_id(instance, **kwargs):
92 if instance.global_id and instance.global_id.id:
95 if instance.global_id and instance.global_id.id:
93 instance.global_id.delete()
96 instance.global_id.delete()
94
97
95
98
96 @receiver(post_save, sender=Attachment)
99 @receiver(post_save, sender=Attachment)
97 def generate_thumb(instance, **kwargs):
100 def generate_thumb(instance, **kwargs):
98 if instance.mimetype in FILE_TYPES_IMAGE:
101 if instance.mimetype in FILE_TYPES_IMAGE:
99 for size in THUMB_SIZES:
102 for size in THUMB_SIZES:
100 (w, h) = size
103 (w, h) = size
101 split = instance.file.name.rsplit('.', 1)
104 split = instance.file.name.rsplit('.', 1)
102 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
105 thumb_name = '%s.%sx%s.%s' % (split[0], w, h, split[1])
103
106
104 if not instance.file.storage.exists(thumb_name):
107 if not instance.file.storage.exists(thumb_name):
105 # you can use another thumbnailing function if you like
108 # you can use another thumbnailing function if you like
106 thumb_content = thumbs.generate_thumb(instance.file, size, split[1])
109 thumb_content = thumbs.generate_thumb(instance.file, size, split[1])
107
110
108 thumb_name_ = instance.file.storage.save(thumb_name, thumb_content)
111 thumb_name_ = instance.file.storage.save(thumb_name, thumb_content)
109
112
110 if not thumb_name == thumb_name_:
113 if not thumb_name == thumb_name_:
111 raise ValueError(
114 raise ValueError(
112 'There is already a file named %s' % thumb_name_)
115 'There is already a file named %s' % thumb_name_)
113
116
114
117
115 @receiver(pre_delete, sender=Post)
118 @receiver(pre_delete, sender=Post)
116 def rebuild_refmap(instance, **kwargs):
119 def rebuild_refmap(instance, **kwargs):
117 for referenced_post in instance.refposts.all():
120 for referenced_post in instance.refposts.all():
118 referenced_post.build_refmap(excluded_ids=[instance.id])
121 referenced_post.build_refmap(excluded_ids=[instance.id])
119 referenced_post.save(update_fields=['refmap'])
122 referenced_post.save(update_fields=['refmap'])
120
123
121
124
122 @receiver(post_delete, sender=Attachment)
125 @receiver(post_delete, sender=Attachment)
123 def delete_file(instance, **kwargs):
126 def delete_file(instance, **kwargs):
124 if instance.is_internal():
127 if instance.is_internal():
125 file = MEDIA_ROOT + instance.file.name
128 file = MEDIA_ROOT + instance.file.name
126 os.remove(file)
129 os.remove(file)
127 if instance.mimetype in FILE_TYPES_IMAGE:
130 if instance.mimetype in FILE_TYPES_IMAGE:
128 for size in THUMB_SIZES:
131 for size in THUMB_SIZES:
129 file_name_parts = instance.file.name.split('.')
132 file_name_parts = instance.file.name.split('.')
130 thumb_file = MEDIA_ROOT + '{}.{}x{}.{}'.format(file_name_parts[0], size[0], size[1], file_name_parts[1])
133 thumb_file = MEDIA_ROOT + '{}.{}x{}.{}'.format(file_name_parts[0], size[0], size[1], file_name_parts[1])
131 os.remove(thumb_file)
134 os.remove(thumb_file)
132
135
@@ -1,160 +1,155 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var CLOSE_BUTTON = '#form-close-button';
26 var CLOSE_BUTTON = '#form-close-button';
27 var REPLY_TO_MSG = '.reply-to-message';
27 var REPLY_TO_MSG = '.reply-to-message';
28 var REPLY_TO_MSG_ID = '#reply-to-message-id';
28 var REPLY_TO_MSG_ID = '#reply-to-message-id';
29
29
30 var $html = $("html, body");
30 var $html = $("html, body");
31
31
32 function moveCaretToEnd(el) {
32 function moveCaretToEnd(el) {
33 var newPos = el.val().length;
33 var newPos = el.val().length;
34 el[0].setSelectionRange(newPos, newPos);
34 el[0].setSelectionRange(newPos, newPos);
35 }
35 }
36
36
37 function getForm() {
37 function getForm() {
38 return $('.post-form-w');
38 return $('.post-form-w');
39 }
39 }
40
40
41 function resetFormPosition() {
41 function resetFormPosition() {
42 var form = getForm();
42 var form = getForm();
43 form.insertAfter($('.thread'));
43 form.insertAfter($('.thread'));
44
44
45 $(CLOSE_BUTTON).hide();
45 $(CLOSE_BUTTON).hide();
46 $(REPLY_TO_MSG).hide();
46 $(REPLY_TO_MSG).hide();
47 }
47 }
48
48
49 function showFormAfter(blockToInsertAfter) {
49 function showFormAfter(blockToInsertAfter) {
50 var form = getForm();
50 var form = getForm();
51 form.insertAfter(blockToInsertAfter);
51 form.insertAfter(blockToInsertAfter);
52
52
53 $(CLOSE_BUTTON).show();
53 $(CLOSE_BUTTON).show();
54 form.show();
54 form.show();
55 $(REPLY_TO_MSG_ID).text(blockToInsertAfter.attr('id'));
55 $(REPLY_TO_MSG_ID).text(blockToInsertAfter.attr('id'));
56 $(REPLY_TO_MSG).show();
56 $(REPLY_TO_MSG).show();
57 }
57 }
58
58
59 function addQuickReply(postId) {
59 function addQuickReply(postId) {
60 // If we click "reply" on the same post, it means "cancel"
60 var blockToInsert = null;
61 if (getForm().prev().attr('id') == postId) {
61 var textAreaJq = getPostTextarea();
62 resetFormPosition();
62 var postLinkRaw = '[post]' + postId + '[/post]'
63 } else {
63 var textToAdd = '';
64 var blockToInsert = null;
65 var textAreaJq = getPostTextarea();
66 var postLinkRaw = '[post]' + postId + '[/post]'
67 var textToAdd = '';
68
64
69 if (postId != null) {
65 if (postId != null) {
70 var post = $('#' + postId);
66 var post = $('#' + postId);
71
67
72 // If this is not OP, add reflink to the post. If there already is
68 // If this is not OP, add reflink to the post. If there already is
73 // the same reflink, don't add it again.
69 // the same reflink, don't add it again.
74 var postText = textAreaJq.val();
70 var postText = textAreaJq.val();
75 if (!post.is(':first-child') && postText.indexOf(postLinkRaw) < 0) {
71 if (!post.is(':first-child') && postText.indexOf(postLinkRaw) < 0) {
76 // Insert line break if none is present.
72 // Insert line break if none is present.
77 if (postText.length > 0 && !postText.endsWith('\n') && !postText.endsWith('\r')) {
73 if (postText.length > 0 && !postText.endsWith('\n') && !postText.endsWith('\r')) {
78 textToAdd += '\n';
74 textToAdd += '\n';
79 }
80 textToAdd += postLinkRaw + '\n';
81 }
75 }
76 textToAdd += postLinkRaw + '\n';
77 }
82
78
83 textAreaJq.val(textAreaJq.val()+ textToAdd);
79 textAreaJq.val(textAreaJq.val()+ textToAdd);
84 blockToInsert = post;
80 blockToInsert = post;
85 } else {
81 } else {
86 blockToInsert = $('.thread');
82 blockToInsert = $('.thread');
87 }
83 }
88 showFormAfter(blockToInsert);
84 showFormAfter(blockToInsert);
89
90 textAreaJq.focus();
91
85
92 moveCaretToEnd(textAreaJq);
86 textAreaJq.focus();
93 }
87
88 moveCaretToEnd(textAreaJq);
94 }
89 }
95
90
96 function addQuickQuote() {
91 function addQuickQuote() {
97 var textAreaJq = getPostTextarea();
92 var textAreaJq = getPostTextarea();
98
93
99 var quoteButton = $("#quote-button");
94 var quoteButton = $("#quote-button");
100 var postId = quoteButton.attr('data-post-id');
95 var postId = quoteButton.attr('data-post-id');
101 if (postId != null && getForm().prev().attr('id') != postId) {
96 if (postId != null) {
102 addQuickReply(postId);
97 addQuickReply(postId);
103 }
98 }
104
99
105 var textToAdd = '';
100 var textToAdd = '';
106 var selection = window.getSelection().toString();
101 var selection = window.getSelection().toString();
107 if (selection.length == 0) {
102 if (selection.length == 0) {
108 selection = quoteButton.attr('data-text');
103 selection = quoteButton.attr('data-text');
109 }
104 }
110 if (selection.length > 0) {
105 if (selection.length > 0) {
111 textToAdd += '[quote]' + selection + '[/quote]\n';
106 textToAdd += '[quote]' + selection + '[/quote]\n';
112 }
107 }
113
108
114 textAreaJq.val(textAreaJq.val() + textToAdd);
109 textAreaJq.val(textAreaJq.val() + textToAdd);
115
110
116 textAreaJq.focus();
111 textAreaJq.focus();
117
112
118 moveCaretToEnd(textAreaJq);
113 moveCaretToEnd(textAreaJq);
119 }
114 }
120
115
121 function scrollToBottom() {
116 function scrollToBottom() {
122 $html.animate({scrollTop: $html.height()}, "fast");
117 $html.animate({scrollTop: $html.height()}, "fast");
123 }
118 }
124
119
125 function showQuoteButton() {
120 function showQuoteButton() {
126 var selection = window.getSelection().getRangeAt(0).getBoundingClientRect();
121 var selection = window.getSelection().getRangeAt(0).getBoundingClientRect();
127 var quoteButton = $("#quote-button");
122 var quoteButton = $("#quote-button");
128 if (selection.width > 0) {
123 if (selection.width > 0) {
129 // quoteButton.offset({ top: selection.top - selection.height, left: selection.left });
124 // quoteButton.offset({ top: selection.top - selection.height, left: selection.left });
130 quoteButton.css({top: selection.top + $(window).scrollTop() - 30, left: selection.left});
125 quoteButton.css({top: selection.top + $(window).scrollTop() - 30, left: selection.left});
131 quoteButton.show();
126 quoteButton.show();
132
127
133 var text = window.getSelection().toString();
128 var text = window.getSelection().toString();
134 quoteButton.attr('data-text', text);
129 quoteButton.attr('data-text', text);
135
130
136 var rect = window.getSelection().getRangeAt(0).getBoundingClientRect();
131 var rect = window.getSelection().getRangeAt(0).getBoundingClientRect();
137 var element = $(document.elementFromPoint(rect.x, rect.y));
132 var element = $(document.elementFromPoint(rect.x, rect.y));
138 var postId = null;
133 var postId = null;
139 if (element.hasClass('post')) {
134 if (element.hasClass('post')) {
140 postId = element.attr('id');
135 postId = element.attr('id');
141 } else {
136 } else {
142 var postParent = element.parents('.post');
137 var postParent = element.parents('.post');
143 if (postParent.length > 0) {
138 if (postParent.length > 0) {
144 postId = postParent.attr('id');
139 postId = postParent.attr('id');
145 }
140 }
146 }
141 }
147 quoteButton.attr('data-post-id', postId);
142 quoteButton.attr('data-post-id', postId);
148 } else {
143 } else {
149 quoteButton.hide();
144 quoteButton.hide();
150 }
145 }
151 }
146 }
152
147
153 $(document).ready(function() {
148 $(document).ready(function() {
154 $('body').on('mouseup', function() {
149 $('body').on('mouseup', function() {
155 showQuoteButton();
150 showQuoteButton();
156 });
151 });
157 $("#quote-button").click(function() {
152 $("#quote-button").click(function() {
158 addQuickQuote();
153 addQuickQuote();
159 })
154 })
160 });
155 });
@@ -1,91 +1,90 b''
1 from base64 import b64encode
2 import logging
1 import logging
3
2
4 from django.test import TestCase
3 from django.test import TestCase
5 from boards.models import KeyPair, GlobalId, Post, Signature
4 from boards.models import KeyPair, GlobalId, Post, Signature
6 from boards.models.post.sync import SyncManager
5 from boards.models.post.sync import SyncManager
7
6
8 logger = logging.getLogger(__name__)
7 logger = logging.getLogger(__name__)
9
8
10
9
11 class KeyTest(TestCase):
10 class KeyTest(TestCase):
12 def test_create_key(self):
11 def test_create_key(self):
13 key = KeyPair.objects.generate_key('ecdsa')
12 key = KeyPair.objects.generate_key('ecdsa')
14
13
15 self.assertIsNotNone(key, 'The key was not created.')
14 self.assertIsNotNone(key, 'The key was not created.')
16
15
17 def test_validation(self):
16 def test_validation(self):
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
17 key = KeyPair.objects.generate_key(key_type='ecdsa')
19 message = 'msg'
18 message = 'msg'
20 signature_value = key.sign(message)
19 signature_value = key.sign(message)
21
20
22 signature = Signature(key_type='ecdsa', key=key.public_key,
21 signature = Signature(key_type='ecdsa', key=key.public_key,
23 signature=signature_value)
22 signature=signature_value)
24 valid = KeyPair.objects.verify(signature, message)
23 valid = KeyPair.objects.verify(signature, message)
25
24
26 self.assertTrue(valid, 'Message verification failed.')
25 self.assertTrue(valid, 'Message verification failed.')
27
26
28 def test_primary_constraint(self):
27 def test_primary_constraint(self):
29 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
28 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
30
29
31 with self.assertRaises(Exception):
30 with self.assertRaises(Exception):
32 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
31 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
33
32
34 def test_model_id_save(self):
33 def test_model_id_save(self):
35 model_id = GlobalId(key_type='test', key='test key', local_id='1')
34 model_id = GlobalId(key_type='test', key='test key', local_id='1')
36 model_id.save()
35 model_id.save()
37
36
38 def test_request_get(self):
37 def test_request_get(self):
39 post = self._create_post_with_key()
38 post = self._create_post_with_key()
40
39
41 request = GlobalId.objects.generate_request_get([post.global_id])
40 request = SyncManager.generate_request_get([post.global_id])
42 logger.debug(request)
41 logger.debug(request)
43
42
44 key = KeyPair.objects.get(primary=True)
43 key = KeyPair.objects.get(primary=True)
45 self.assertTrue('<request type="get" version="1.0">'
44 self.assertTrue('<request type="get" version="1.0">'
46 '<model name="post" version="1.0">'
45 '<model name="post" version="1.0">'
47 '<id key="%s" local-id="1" type="%s" />'
46 '<id key="%s" local-id="1" type="%s" />'
48 '</model>'
47 '</model>'
49 '</request>' % (
48 '</request>' % (
50 key.public_key,
49 key.public_key,
51 key.key_type,
50 key.key_type,
52 ) in request,
51 ) in request,
53 'Wrong XML generated for the GET request.')
52 'Wrong XML generated for the GET request.')
54
53
55 def test_response_get(self):
54 def test_response_get(self):
56 post = self._create_post_with_key()
55 post = self._create_post_with_key()
57 reply_post = Post.objects.create_post(title='test_title',
56 reply_post = Post.objects.create_post(title='test_title',
58 text='[post]%d[/post]' % post.id,
57 text='[post]%d[/post]' % post.id,
59 thread=post.get_thread())
58 thread=post.get_thread())
60
59
61 response = SyncManager.generate_response_get([reply_post])
60 response = SyncManager.generate_response_get([reply_post])
62 logger.debug(response)
61 logger.debug(response)
63
62
64 key = KeyPair.objects.get(primary=True)
63 key = KeyPair.objects.get(primary=True)
65 self.assertTrue('<status>success</status>'
64 self.assertTrue('<status>success</status>'
66 '<models>'
65 '<models>'
67 '<model name="post">'
66 '<model name="post">'
68 '<content>'
67 '<content>'
69 '<id key="%s" local-id="%d" type="%s" />'
68 '<id key="%s" local-id="%d" type="%s" />'
70 '<title>test_title</title>'
69 '<title>test_title</title>'
71 '<text>[post]%s[/post]</text>'
70 '<text>[post]%s[/post]</text>'
72 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
71 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
73 '<pub-time>%s</pub-time>'
72 '<pub-time>%s</pub-time>'
74 '<version>%s</version>'
73 '<version>%s</version>'
75 '</content>' % (
74 '</content>' % (
76 key.public_key,
75 key.public_key,
77 reply_post.id,
76 reply_post.id,
78 key.key_type,
77 key.key_type,
79 str(post.global_id),
78 str(post.global_id),
80 key.public_key,
79 key.public_key,
81 post.id,
80 post.id,
82 key.key_type,
81 key.key_type,
83 str(reply_post.get_pub_time_str()),
82 str(reply_post.get_pub_time_str()),
84 post.version,
83 post.version,
85 ) in response,
84 ) in response,
86 'Wrong XML generated for the GET response.')
85 'Wrong XML generated for the GET response.')
87
86
88 def _create_post_with_key(self):
87 def _create_post_with_key(self):
89 KeyPair.objects.generate_key(primary=True)
88 KeyPair.objects.generate_key(primary=True)
90
89
91 return Post.objects.create_post(title='test_title', text='test_text')
90 return Post.objects.create_post(title='test_title', text='test_text')
@@ -1,105 +1,214 b''
1 from django.test import TestCase
1 from django.test import TestCase
2
2
3 from boards.models import KeyPair, Post, Tag
3 from boards.models import KeyPair, Post, Tag
4 from boards.models.post.sync import SyncManager
4 from boards.models.post.sync import SyncManager
5 from boards.tests.mocks import MockRequest
5 from boards.tests.mocks import MockRequest
6 from boards.views.sync import response_get
6 from boards.views.sync import response_get, response_list
7
7
8 __author__ = 'neko259'
8 __author__ = 'neko259'
9
9
10
10
11 class SyncTest(TestCase):
11 class SyncTest(TestCase):
12 def test_get(self):
12 def test_get(self):
13 """
13 """
14 Forms a GET request of a post and checks the response.
14 Forms a GET request of a post and checks the response.
15 """
15 """
16
16
17 key = KeyPair.objects.generate_key(primary=True)
17 key = KeyPair.objects.generate_key(primary=True)
18 tag = Tag.objects.create(name='tag1')
18 tag = Tag.objects.create(name='tag1')
19 post = Post.objects.create_post(title='test_title',
19 post = Post.objects.create_post(title='test_title',
20 text='test_text\rline two',
20 text='test_text\rline two',
21 tags=[tag])
21 tags=[tag])
22
22
23 request = MockRequest()
23 request = MockRequest()
24 request.body = (
24 request.body = (
25 '<request type="get" version="1.0">'
25 '<request type="get" version="1.0">'
26 '<model name="post" version="1.0">'
26 '<model name="post" version="1.0">'
27 '<id key="%s" local-id="%d" type="%s" />'
27 '<id key="%s" local-id="%d" type="%s" />'
28 '</model>'
28 '</model>'
29 '</request>' % (post.global_id.key,
29 '</request>' % (post.global_id.key,
30 post.id,
30 post.id,
31 post.global_id.key_type)
31 post.global_id.key_type)
32 )
32 )
33
33
34 response = response_get(request).content.decode()
34 response = response_get(request).content.decode()
35 self.assertTrue(
35 self.assertTrue(
36 '<status>success</status>'
36 '<status>success</status>'
37 '<models>'
37 '<models>'
38 '<model name="post">'
38 '<model name="post">'
39 '<content>'
39 '<content>'
40 '<id key="%s" local-id="%d" type="%s" />'
40 '<id key="%s" local-id="%d" type="%s" />'
41 '<title>%s</title>'
41 '<title>%s</title>'
42 '<text>%s</text>'
42 '<text>%s</text>'
43 '<tags><tag>%s</tag></tags>'
43 '<tags><tag>%s</tag></tags>'
44 '<pub-time>%s</pub-time>'
44 '<pub-time>%s</pub-time>'
45 '<version>%s</version>'
45 '<version>%s</version>'
46 '</content>' % (
46 '</content>' % (
47 post.global_id.key,
47 post.global_id.key,
48 post.global_id.local_id,
48 post.global_id.local_id,
49 post.global_id.key_type,
49 post.global_id.key_type,
50 post.title,
50 post.title,
51 post.get_sync_text(),
51 post.get_sync_text(),
52 post.get_thread().get_tags().first().name,
52 post.get_thread().get_tags().first().name,
53 post.get_pub_time_str(),
53 post.get_pub_time_str(),
54 post.version,
54 post.version,
55 ) in response,
55 ) in response,
56 'Wrong response generated for the GET request.')
56 'Wrong response generated for the GET request.')
57
57
58 post.delete()
58 post.delete()
59 key.delete()
59 key.delete()
60
60
61 KeyPair.objects.generate_key(primary=True)
61 KeyPair.objects.generate_key(primary=True)
62
62
63 SyncManager.parse_response_get(response, None)
63 SyncManager.parse_response_get(response, None)
64 self.assertEqual(1, Post.objects.count(),
64 self.assertEqual(1, Post.objects.count(),
65 'Post was not created from XML response.')
65 'Post was not created from XML response.')
66
66
67 parsed_post = Post.objects.first()
67 parsed_post = Post.objects.first()
68 self.assertEqual('tag1',
68 self.assertEqual('tag1',
69 parsed_post.get_thread().get_tags().first().name,
69 parsed_post.get_thread().get_tags().first().name,
70 'Invalid tag was parsed.')
70 'Invalid tag was parsed.')
71
71
72 SyncManager.parse_response_get(response, None)
72 SyncManager.parse_response_get(response, None)
73 self.assertEqual(1, Post.objects.count(),
73 self.assertEqual(1, Post.objects.count(),
74 'The same post was imported twice.')
74 'The same post was imported twice.')
75
75
76 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
76 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
77 'Signature was not saved.')
77 'Signature was not saved.')
78
78
79 post = parsed_post
79 post = parsed_post
80
80
81 # Trying to sync the same once more
81 # Trying to sync the same once more
82 response = response_get(request).content.decode()
82 response = response_get(request).content.decode()
83
83
84 self.assertTrue(
84 self.assertTrue(
85 '<status>success</status>'
85 '<status>success</status>'
86 '<models>'
86 '<models>'
87 '<model name="post">'
87 '<model name="post">'
88 '<content>'
88 '<content>'
89 '<id key="%s" local-id="%d" type="%s" />'
89 '<id key="%s" local-id="%d" type="%s" />'
90 '<title>%s</title>'
90 '<title>%s</title>'
91 '<text>%s</text>'
91 '<text>%s</text>'
92 '<tags><tag>%s</tag></tags>'
92 '<tags><tag>%s</tag></tags>'
93 '<pub-time>%s</pub-time>'
93 '<pub-time>%s</pub-time>'
94 '<version>%s</version>'
94 '<version>%s</version>'
95 '</content>' % (
95 '</content>' % (
96 post.global_id.key,
96 post.global_id.key,
97 post.global_id.local_id,
97 post.global_id.local_id,
98 post.global_id.key_type,
98 post.global_id.key_type,
99 post.title,
99 post.title,
100 post.get_sync_text(),
100 post.get_sync_text(),
101 post.get_thread().get_tags().first().name,
101 post.get_thread().get_tags().first().name,
102 post.get_pub_time_str(),
102 post.get_pub_time_str(),
103 post.version,
103 post.version,
104 ) in response,
104 ) in response,
105 'Wrong response generated for the GET request.')
105 'Wrong response generated for the GET request.')
106
107 def test_list_all(self):
108 key = KeyPair.objects.generate_key(primary=True)
109 tag = Tag.objects.create(name='tag1')
110 post = Post.objects.create_post(title='test_title',
111 text='test_text\rline two',
112 tags=[tag])
113 post2 = Post.objects.create_post(title='test title 2',
114 text='test text 2',
115 tags=[tag])
116
117 request_all = MockRequest()
118 request_all.body = (
119 '<request type="list" version="1.0">'
120 '<model name="post" version="1.0">'
121 '</model>'
122 '</request>'
123 )
124
125 response_all = response_list(request_all).content.decode()
126 self.assertTrue(
127 '<status>success</status>'
128 '<models>'
129 '<model>'
130 '<id key="{}" local-id="{}" type="{}" />'
131 '<version>{}</version>'
132 '</model>'
133 '<model>'
134 '<id key="{}" local-id="{}" type="{}" />'
135 '<version>{}</version>'
136 '</model>'
137 '</models>'.format(
138 post.global_id.key,
139 post.global_id.local_id,
140 post.global_id.key_type,
141 post.version,
142 post2.global_id.key,
143 post2.global_id.local_id,
144 post2.global_id.key_type,
145 post2.version,
146 ) in response_all,
147 'Wrong response generated for the LIST request for all posts.')
148
149 def test_list_existing_thread(self):
150 key = KeyPair.objects.generate_key(primary=True)
151 tag = Tag.objects.create(name='tag1')
152 post = Post.objects.create_post(title='test_title',
153 text='test_text\rline two',
154 tags=[tag])
155 post2 = Post.objects.create_post(title='test title 2',
156 text='test text 2',
157 tags=[tag])
158
159 request_thread = MockRequest()
160 request_thread.body = (
161 '<request type="list" version="1.0">'
162 '<model name="post" version="1.0">'
163 '<thread>{}</thread>'
164 '</model>'
165 '</request>'.format(
166 post.id,
167 )
168 )
169
170 response_thread = response_list(request_thread).content.decode()
171 self.assertTrue(
172 '<status>success</status>'
173 '<models>'
174 '<model>'
175 '<id key="{}" local-id="{}" type="{}" />'
176 '<version>{}</version>'
177 '</model>'
178 '</models>'.format(
179 post.global_id.key,
180 post.global_id.local_id,
181 post.global_id.key_type,
182 post.version,
183 ) in response_thread,
184 'Wrong response generated for the LIST request for posts of '
185 'existing thread.')
186
187 def test_list_non_existing_thread(self):
188 key = KeyPair.objects.generate_key(primary=True)
189 tag = Tag.objects.create(name='tag1')
190 post = Post.objects.create_post(title='test_title',
191 text='test_text\rline two',
192 tags=[tag])
193 post2 = Post.objects.create_post(title='test title 2',
194 text='test text 2',
195 tags=[tag])
196
197 request_thread = MockRequest()
198 request_thread.body = (
199 '<request type="list" version="1.0">'
200 '<model name="post" version="1.0">'
201 '<thread>{}</thread>'
202 '</model>'
203 '</request>'.format(
204 0,
205 )
206 )
207
208 response_thread = response_list(request_thread).content.decode()
209 self.assertTrue(
210 '<status>success</status>'
211 '<models />'
212 in response_thread,
213 'Wrong response generated for the LIST request for posts of '
214 'non-existing thread.')
@@ -1,55 +1,79 b''
1 import logging
2
1 import xml.etree.ElementTree as et
3 import xml.etree.ElementTree as et
2
4
3 from django.http import HttpResponse, Http404
5 from django.http import HttpResponse, Http404
6
7 from boards.abstracts.sync_filters import ThreadFilter, TAG_THREAD
4 from boards.models import GlobalId, Post
8 from boards.models import GlobalId, Post
5 from boards.models.post.sync import SyncManager
9 from boards.models.post.sync import SyncManager
6
10
7
11
12 logger = logging.getLogger('boards.sync')
13
14
15 FILTERS = {
16 TAG_THREAD: ThreadFilter,
17 }
18
19
8 def response_list(request):
20 def response_list(request):
9 request_xml = request.body
21 request_xml = request.body
10
22
23 filters = []
24
11 if request_xml is None or len(request_xml) == 0:
25 if request_xml is None or len(request_xml) == 0:
12 return HttpResponse(content='Use the API')
26 return HttpResponse(content='Use the API')
27 else:
28 root_tag = et.fromstring(request_xml)
29 model_tag = root_tag[0]
13
30
14 response_xml = SyncManager.generate_response_list()
31 for tag_filter in model_tag:
32 filter_name = tag_filter.tag
33 model_filter = FILTERS.get(filter_name)(tag_filter)
34 if not model_filter:
35 logger.warning('Unavailable filter: {}'.format(filter_name))
36 filters.append(model_filter)
37
38 response_xml = SyncManager.generate_response_list(filters)
15
39
16 return HttpResponse(content=response_xml)
40 return HttpResponse(content=response_xml)
17
41
18
42
19 def response_get(request):
43 def response_get(request):
20 """
44 """
21 Processes a GET request with post ID list and returns the posts XML list.
45 Processes a GET request with post ID list and returns the posts XML list.
22 Request should contain an 'xml' post attribute with the actual request XML.
46 Request should contain an 'xml' post attribute with the actual request XML.
23 """
47 """
24
48
25 request_xml = request.body
49 request_xml = request.body
26
50
27 if request_xml is None or len(request_xml) == 0:
51 if request_xml is None or len(request_xml) == 0:
28 return HttpResponse(content='Use the API')
52 return HttpResponse(content='Use the API')
29
53
30 posts = []
54 posts = []
31
55
32 root_tag = et.fromstring(request_xml)
56 root_tag = et.fromstring(request_xml)
33 model_tag = root_tag[0]
57 model_tag = root_tag[0]
34 for id_tag in model_tag:
58 for id_tag in model_tag:
35 global_id, exists = GlobalId.from_xml_element(id_tag)
59 global_id, exists = GlobalId.from_xml_element(id_tag)
36 if exists:
60 if exists:
37 posts.append(Post.objects.get(global_id=global_id))
61 posts.append(Post.objects.get(global_id=global_id))
38
62
39 response_xml = SyncManager.generate_response_get(posts)
63 response_xml = SyncManager.generate_response_get(posts)
40
64
41 return HttpResponse(content=response_xml)
65 return HttpResponse(content=response_xml)
42
66
43
67
44 def get_post_sync_data(request, post_id):
68 def get_post_sync_data(request, post_id):
45 try:
69 try:
46 post = Post.objects.get(id=post_id)
70 post = Post.objects.get(id=post_id)
47 except Post.DoesNotExist:
71 except Post.DoesNotExist:
48 raise Http404()
72 raise Http404()
49
73
50 xml_str = SyncManager.generate_response_get([post])
74 xml_str = SyncManager.generate_response_get([post])
51
75
52 return HttpResponse(
76 return HttpResponse(
53 content_type='text/xml; charset=utf-8',
77 content_type='text/xml; charset=utf-8',
54 content=xml_str,
78 content=xml_str,
55 )
79 )
@@ -1,66 +1,66 b''
1 # INTRO #
1 # INTRO #
2
2
3 This project aims to create centralized forum-like discussion platform with
3 This project aims to create centralized forum-like discussion platform with
4 anonymity in mind.
4 anonymity in mind.
5
5
6 Main repository: https://bitbucket.org/neko259/neboard/
6 Main repository: https://bitbucket.org/neko259/neboard/
7
7
8 Site: http://neboard.me/
8 Site: http://neboard.me/
9
9
10 # INSTALLATION #
10 # INSTALLATION #
11
11
12 1. Download application and move inside it:
12 1. Download application and move inside it:
13
13
14 `hg clone https://bitbucket.org/neko259/neboard`
14 `hg clone https://bitbucket.org/neko259/neboard`
15
15
16 `cd neboard`
16 `cd neboard`
17
17
18 2. Install all application dependencies:
18 2. Install all application dependencies:
19
19
20 Some minimal system-wide depenencies:
20 Some minimal system-wide depenencies:
21
21
22 * python3
22 * python3
23 * pip/pip3
23 * pip/pip3
24 * jpeg
24 * jpeg
25
25
26 Python dependencies:
26 Python dependencies:
27
27
28 `pip3 install -r requirements.txt`
28 `pip3 install -r requirements.txt`
29
29
30 You can use virtualenv to speed up the process or avoid conflicts.
30 You can use virtualenv to speed up the process or avoid conflicts.
31
31
32 3. Setup a database in `neboard/settings.py`. You can also change other settings like search engine.
32 3. Setup a database in `neboard/settings.py`. You can also change other settings like search engine.
33
33
34 Depending on configured database and search engine, you need to install corresponding dependencies manually.
34 Depending on configured database and search engine, you need to install corresponding dependencies manually.
35
35
36 Default database is *sqlite*, default search engine is *simple*.
36 Default database is *sqlite*. If you want to change the database backend, refer to the django documentation for the correct settings. Please note that sqlite accepts only one connection at a time, so you won't be able to run 2 servers or a server and a sync at the same time.
37
37
38 4. Setup SECRET_KEY to a secret value in `neboard/settings.py
38 4. Setup SECRET_KEY to a secret value in `neboard/settings.py
39 5. Run `./manage.py migrate` to apply all migrations
39 5. Run `./manage.py migrate` to apply all migrations
40 6. Apply config changes to `boards/config/config.ini`. You can see the default settings in `boards/config/default_config.ini`
40 6. Apply config changes to `boards/config/settings.ini`. You can see the default settings in `boards/config/default_config.ini`(do not delete or overwrite it).
41 7. If you want to use decetral engine, run `./manage.py generate_keypair` to generate keys
41 7. If you want to use decetral engine, run `./manage.py generate_keypair` to generate keys
42
42
43 # RUNNING #
43 # RUNNING #
44
44
45 You can run the server using django default embedded webserver by running:
45 You can run the server using django default embedded webserver by running:
46
46
47 ./manage.py runserver <address>:<port>
47 ./manage.py runserver <address>:<port>
48
48
49 See django-admin command help for details.
49 See django-admin command help for details.
50
50
51 Also consider using wsgi or fcgi interfaces on production servers.
51 Also consider using wsgi or fcgi interfaces on production servers.
52
52
53 When running for the first time, you need to setup at least one section tag.
53 When running for the first time, you need to setup at least one section tag.
54 Go to the admin page and manually create one tag with "required" property set.
54 Go to the admin page and manually create one tag with "required" property set.
55
55
56 # UPGRADE #
56 # UPGRADE #
57
57
58 1. Backup your project data.
58 1. Backup your project data.
59 2. Copy the project contents over the old project directory
59 2. Copy the project contents over the old project directory
60 3. Run migrations by `./manage.py migrate`
60 3. Run migrations by `./manage.py migrate`
61
61
62 You can also just clone the mercurial project and pull it to update
62 You can also just clone the mercurial project and pull it to update
63
63
64 # CONCLUSION #
64 # CONCLUSION #
65
65
66 Enjoy our software and thank you!
66 Enjoy our software and thank you!
General Comments 0
You need to be logged in to leave comments. Login now