##// END OF EJS Templates
Merged the decentral branch into default branch
neko259 -
r1546:80ed7ef8 merge default
parent child Browse files
Show More
@@ -0,0 +1,11 b''
1 from django.apps import AppConfig
2
3
4 class BoardsAppConfig(AppConfig):
5 name = 'boards'
6 verbose_name = 'Boards'
7
8 def ready(self):
9 super().ready()
10
11 import boards.signals No newline at end of file
@@ -0,0 +1,21 b''
1 __author__ = 'neko259'
2
3
4 from django.core.management import BaseCommand
5 from django.db import transaction
6
7 from boards.models import KeyPair, Post
8
9
10 class Command(BaseCommand):
11 help = 'Generates the new keypair. The first one will be primary.'
12
13 @transaction.atomic
14 def handle(self, *args, **options):
15 first_key = not KeyPair.objects.has_primary()
16 key = KeyPair.objects.generate_key(
17 primary=first_key)
18 print(key)
19
20 for post in Post.objects.filter(global_id=None):
21 post.set_global_id()
@@ -0,0 +1,80 b''
1 import re
2 import xml.etree.ElementTree as ET
3
4 import httplib2
5 from django.core.management import BaseCommand
6
7 from boards.models import GlobalId
8 from boards.models.post.sync import SyncManager
9
10 __author__ = 'neko259'
11
12
13 REGEX_GLOBAL_ID = re.compile(r'(\w+)::([\w\+/]+)::(\d+)')
14
15
16 class Command(BaseCommand):
17 help = 'Send a sync or get request to the server.'
18
19 def add_arguments(self, parser):
20 parser.add_argument('url', type=str, help='Server root url')
21 parser.add_argument('--global-id', type=str, default='',
22 help='Post global ID')
23
24 def handle(self, *args, **options):
25 url = options.get('url')
26
27 pull_url = url + 'api/sync/pull/'
28 get_url = url + 'api/sync/get/'
29 file_url = url[:-1]
30
31 global_id_str = options.get('global_id')
32 if global_id_str:
33 match = REGEX_GLOBAL_ID.match(global_id_str)
34 if match:
35 key_type = match.group(1)
36 key = match.group(2)
37 local_id = match.group(3)
38
39 global_id = GlobalId(key_type=key_type, key=key,
40 local_id=local_id)
41
42 xml = GlobalId.objects.generate_request_get([global_id])
43 # body = urllib.parse.urlencode(data)
44 h = httplib2.Http()
45 response, content = h.request(get_url, method="POST", body=xml)
46
47 SyncManager.parse_response_get(content, file_url)
48 else:
49 raise Exception('Invalid global ID')
50 else:
51 h = httplib2.Http()
52 xml = GlobalId.objects.generate_request_pull()
53 response, content = h.request(pull_url, method="POST", body=xml)
54
55 print(content.decode() + '\n')
56
57 root = ET.fromstring(content)
58 status = root.findall('status')[0].text
59 if status == 'success':
60 ids_to_sync = list()
61
62 models = root.findall('models')[0]
63 for model in models:
64 global_id, exists = GlobalId.from_xml_element(model)
65 if not exists:
66 print(global_id)
67 ids_to_sync.append(global_id)
68 print()
69
70 if len(ids_to_sync) > 0:
71 xml = GlobalId.objects.generate_request_get(ids_to_sync)
72 # body = urllib.parse.urlencode(data)
73 h = httplib2.Http()
74 response, content = h.request(get_url, method="POST", body=xml)
75
76 SyncManager.parse_response_get(content, file_url)
77 else:
78 print('Nothing to get, everything synced')
79 else:
80 raise Exception('Invalid response status')
@@ -0,0 +1,48 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0025_auto_20150825_2049'),
11 ]
12
13 operations = [
14 migrations.CreateModel(
15 name='GlobalId',
16 fields=[
17 ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
18 ('key', models.TextField()),
19 ('key_type', models.TextField()),
20 ('local_id', models.IntegerField()),
21 ],
22 ),
23 migrations.CreateModel(
24 name='KeyPair',
25 fields=[
26 ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
27 ('public_key', models.TextField()),
28 ('private_key', models.TextField()),
29 ('key_type', models.TextField()),
30 ('primary', models.BooleanField(default=False)),
31 ],
32 ),
33 migrations.CreateModel(
34 name='Signature',
35 fields=[
36 ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)),
37 ('key_type', models.TextField()),
38 ('key', models.TextField()),
39 ('signature', models.TextField()),
40 ('global_id', models.ForeignKey(to='boards.GlobalId')),
41 ],
42 ),
43 migrations.AddField(
44 model_name='post',
45 name='global_id',
46 field=models.OneToOneField(to='boards.GlobalId', null=True, blank=True),
47 ),
48 ]
@@ -0,0 +1,15 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0030_auto_20150929_1816'),
11 ('boards', '0026_auto_20150830_2006'),
12 ]
13
14 operations = [
15 ]
@@ -0,0 +1,15 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0031_merge'),
11 ('boards', '0035_auto_20151021_1346'),
12 ]
13
14 operations = [
15 ]
@@ -0,0 +1,15 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0040_thread_monochrome'),
11 ('boards', '0036_merge'),
12 ]
13
14 operations = [
15 ]
@@ -0,0 +1,16 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-04-29 15:52
3 from __future__ import unicode_literals
4
5 from django.db import migrations
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0041_merge'),
12 ('boards', '0042_auto_20160422_1053'),
13 ]
14
15 operations = [
16 ]
@@ -0,0 +1,20 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-05-04 15:36
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', '0043_merge'),
12 ]
13
14 operations = [
15 migrations.AddField(
16 model_name='globalid',
17 name='content',
18 field=models.TextField(blank=True, null=True),
19 ),
20 ]
@@ -0,0 +1,270 b''
1 import xml.etree.ElementTree as et
2
3 from boards.models.attachment.downloaders import download
4 from boards.utils import get_file_mimetype, get_file_hash
5 from django.db import transaction
6 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
7
8 EXCEPTION_NODE = 'Sync node returned an error: {}'
9 EXCEPTION_OP = 'Load the OP first'
10 EXCEPTION_DOWNLOAD = 'File was not downloaded'
11 EXCEPTION_HASH = 'File hash does not match attachment hash'
12 EXCEPTION_SIGNATURE = 'Invalid model signature for {}'
13 ENCODING_UNICODE = 'unicode'
14
15 TAG_MODEL = 'model'
16 TAG_REQUEST = 'request'
17 TAG_RESPONSE = 'response'
18 TAG_ID = 'id'
19 TAG_STATUS = 'status'
20 TAG_MODELS = 'models'
21 TAG_TITLE = 'title'
22 TAG_TEXT = 'text'
23 TAG_THREAD = 'thread'
24 TAG_PUB_TIME = 'pub-time'
25 TAG_SIGNATURES = 'signatures'
26 TAG_SIGNATURE = 'signature'
27 TAG_CONTENT = 'content'
28 TAG_ATTACHMENTS = 'attachments'
29 TAG_ATTACHMENT = 'attachment'
30 TAG_TAGS = 'tags'
31 TAG_TAG = 'tag'
32 TAG_ATTACHMENT_REFS = 'attachment-refs'
33 TAG_ATTACHMENT_REF = 'attachment-ref'
34
35 TYPE_GET = 'get'
36
37 ATTR_VERSION = 'version'
38 ATTR_TYPE = 'type'
39 ATTR_NAME = 'name'
40 ATTR_VALUE = 'value'
41 ATTR_MIMETYPE = 'mimetype'
42 ATTR_KEY = 'key'
43 ATTR_REF = 'ref'
44 ATTR_URL = 'url'
45
46 STATUS_SUCCESS = 'success'
47
48
49 class SyncException(Exception):
50 pass
51
52
53 class SyncManager:
54 @staticmethod
55 def generate_response_get(model_list: list):
56 response = et.Element(TAG_RESPONSE)
57
58 status = et.SubElement(response, TAG_STATUS)
59 status.text = STATUS_SUCCESS
60
61 models = et.SubElement(response, TAG_MODELS)
62
63 for post in model_list:
64 model = et.SubElement(models, TAG_MODEL)
65 model.set(ATTR_NAME, 'post')
66
67 global_id = post.global_id
68
69 images = post.images.all()
70 attachments = post.attachments.all()
71 if global_id.content:
72 model.append(et.fromstring(global_id.content))
73 if len(images) > 0 or len(attachments) > 0:
74 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
75 for image in images:
76 SyncManager._attachment_to_xml(
77 None, attachment_refs, image.image.file,
78 image.hash, image.image.url)
79 for file in attachments:
80 SyncManager._attachment_to_xml(
81 None, attachment_refs, file.file.file,
82 file.hash, file.file.url)
83 else:
84 content_tag = et.SubElement(model, TAG_CONTENT)
85
86 tag_id = et.SubElement(content_tag, TAG_ID)
87 global_id.to_xml_element(tag_id)
88
89 title = et.SubElement(content_tag, TAG_TITLE)
90 title.text = post.title
91
92 text = et.SubElement(content_tag, TAG_TEXT)
93 text.text = post.get_sync_text()
94
95 thread = post.get_thread()
96 if post.is_opening():
97 tag_tags = et.SubElement(content_tag, TAG_TAGS)
98 for tag in thread.get_tags():
99 tag_tag = et.SubElement(tag_tags, TAG_TAG)
100 tag_tag.text = tag.name
101 else:
102 tag_thread = et.SubElement(content_tag, TAG_THREAD)
103 thread_id = et.SubElement(tag_thread, TAG_ID)
104 thread.get_opening_post().global_id.to_xml_element(thread_id)
105
106 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
107 pub_time.text = str(post.get_pub_time_str())
108
109 if len(images) > 0 or len(attachments) > 0:
110 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
111 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
112
113 for image in images:
114 SyncManager._attachment_to_xml(
115 attachments_tag, attachment_refs, image.image.file,
116 image.hash, image.image.url)
117 for file in attachments:
118 SyncManager._attachment_to_xml(
119 attachments_tag, attachment_refs, file.file.file,
120 file.hash, file.file.url)
121
122 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
123 global_id.save()
124
125 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
126 post_signatures = global_id.signature_set.all()
127 if post_signatures:
128 signatures = post_signatures
129 else:
130 key = KeyPair.objects.get(public_key=global_id.key)
131 signature = Signature(
132 key_type=key.key_type,
133 key=key.public_key,
134 signature=key.sign(global_id.content),
135 global_id=global_id,
136 )
137 signature.save()
138 signatures = [signature]
139 for signature in signatures:
140 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
141 signature_tag.set(ATTR_TYPE, signature.key_type)
142 signature_tag.set(ATTR_VALUE, signature.signature)
143 signature_tag.set(ATTR_KEY, signature.key)
144
145 return et.tostring(response, ENCODING_UNICODE)
146
147 @staticmethod
148 @transaction.atomic
149 def parse_response_get(response_xml, hostname):
150 tag_root = et.fromstring(response_xml)
151 tag_status = tag_root.find(TAG_STATUS)
152 if STATUS_SUCCESS == tag_status.text:
153 tag_models = tag_root.find(TAG_MODELS)
154 for tag_model in tag_models:
155 tag_content = tag_model.find(TAG_CONTENT)
156
157 content_str = et.tostring(tag_content, ENCODING_UNICODE)
158 signatures = SyncManager._verify_model(content_str, tag_model)
159
160 tag_id = tag_content.find(TAG_ID)
161 global_id, exists = GlobalId.from_xml_element(tag_id)
162
163 if exists:
164 print('Post with same ID already exists')
165 else:
166 global_id.content = content_str
167 global_id.save()
168 for signature in signatures:
169 signature.global_id = global_id
170 signature.save()
171
172 title = tag_content.find(TAG_TITLE).text or ''
173 text = tag_content.find(TAG_TEXT).text or ''
174 pub_time = tag_content.find(TAG_PUB_TIME).text
175
176 thread = tag_content.find(TAG_THREAD)
177 tags = []
178 if thread:
179 thread_id = thread.find(TAG_ID)
180 op_global_id, exists = GlobalId.from_xml_element(thread_id)
181 if exists:
182 opening_post = Post.objects.get(global_id=op_global_id)
183 else:
184 raise SyncException(EXCEPTION_OP)
185 else:
186 opening_post = None
187 tag_tags = tag_content.find(TAG_TAGS)
188 for tag_tag in tag_tags:
189 tag, created = Tag.objects.get_or_create(
190 name=tag_tag.text)
191 tags.append(tag)
192
193 # TODO Check that the replied posts are already present
194 # before adding new ones
195
196 files = []
197 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
198 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
199 for attachment in tag_attachments:
200 tag_ref = tag_refs.find("{}[@ref='{}']".format(
201 TAG_ATTACHMENT_REF, attachment.text))
202 url = tag_ref.get(ATTR_URL)
203 attached_file = download(hostname + url)
204 if attached_file is None:
205 raise SyncException(EXCEPTION_DOWNLOAD)
206
207 hash = get_file_hash(attached_file)
208 if hash != attachment.text:
209 raise SyncException(EXCEPTION_HASH)
210
211 files.append(attached_file)
212
213 Post.objects.import_post(
214 title=title, text=text, pub_time=pub_time,
215 opening_post=opening_post, tags=tags,
216 global_id=global_id, files=files)
217 else:
218 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
219
220 @staticmethod
221 def generate_response_pull():
222 response = et.Element(TAG_RESPONSE)
223
224 status = et.SubElement(response, TAG_STATUS)
225 status.text = STATUS_SUCCESS
226
227 models = et.SubElement(response, TAG_MODELS)
228
229 for post in Post.objects.all():
230 tag_id = et.SubElement(models, TAG_ID)
231 post.global_id.to_xml_element(tag_id)
232
233 return et.tostring(response, ENCODING_UNICODE)
234
235 @staticmethod
236 def _verify_model(content_str, tag_model):
237 """
238 Verifies all signatures for a single model.
239 """
240
241 signatures = []
242
243 tag_signatures = tag_model.find(TAG_SIGNATURES)
244 for tag_signature in tag_signatures:
245 signature_type = tag_signature.get(ATTR_TYPE)
246 signature_value = tag_signature.get(ATTR_VALUE)
247 signature_key = tag_signature.get(ATTR_KEY)
248
249 signature = Signature(key_type=signature_type,
250 key=signature_key,
251 signature=signature_value)
252
253 if not KeyPair.objects.verify(signature, content_str):
254 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
255
256 signatures.append(signature)
257
258 return signatures
259
260 @staticmethod
261 def _attachment_to_xml(tag_attachments, tag_refs, file, hash, url):
262 if tag_attachments is not None:
263 mimetype = get_file_mimetype(file)
264 attachment = et.SubElement(tag_attachments, TAG_ATTACHMENT)
265 attachment.set(ATTR_MIMETYPE, mimetype)
266 attachment.text = hash
267
268 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
269 attachment_ref.set(ATTR_REF, hash)
270 attachment_ref.set(ATTR_URL, url)
@@ -0,0 +1,144 b''
1 import xml.etree.ElementTree as et
2 from django.db import models
3
4
5 TAG_MODEL = 'model'
6 TAG_REQUEST = 'request'
7 TAG_ID = 'id'
8
9 TYPE_GET = 'get'
10 TYPE_PULL = 'pull'
11
12 ATTR_VERSION = 'version'
13 ATTR_TYPE = 'type'
14 ATTR_NAME = 'name'
15
16 ATTR_KEY = 'key'
17 ATTR_KEY_TYPE = 'type'
18 ATTR_LOCAL_ID = 'local-id'
19
20
21 class GlobalIdManager(models.Manager):
22 def generate_request_get(self, global_id_list: list):
23 """
24 Form a get request from a list of ModelId objects.
25 """
26
27 request = et.Element(TAG_REQUEST)
28 request.set(ATTR_TYPE, TYPE_GET)
29 request.set(ATTR_VERSION, '1.0')
30
31 model = et.SubElement(request, TAG_MODEL)
32 model.set(ATTR_VERSION, '1.0')
33 model.set(ATTR_NAME, 'post')
34
35 for global_id in global_id_list:
36 tag_id = et.SubElement(model, TAG_ID)
37 global_id.to_xml_element(tag_id)
38
39 return et.tostring(request, 'unicode')
40
41 def generate_request_pull(self):
42 """
43 Form a pull request from a list of ModelId objects.
44 """
45
46 request = et.Element(TAG_REQUEST)
47 request.set(ATTR_TYPE, TYPE_PULL)
48 request.set(ATTR_VERSION, '1.0')
49
50 model = et.SubElement(request, TAG_MODEL)
51 model.set(ATTR_VERSION, '1.0')
52 model.set(ATTR_NAME, 'post')
53
54 return et.tostring(request, 'unicode')
55
56 def global_id_exists(self, global_id):
57 """
58 Checks if the same global id already exists in the system.
59 """
60
61 return self.filter(key=global_id.key,
62 key_type=global_id.key_type,
63 local_id=global_id.local_id).exists()
64
65
66 class GlobalId(models.Model):
67 """
68 Global model ID and cache.
69 Key, key type and local ID make a single global identificator of the model.
70 Content is an XML cache of the model that can be passed along between nodes
71 without manual serialization each time.
72 """
73 class Meta:
74 app_label = 'boards'
75
76 objects = GlobalIdManager()
77
78 def __init__(self, *args, **kwargs):
79 models.Model.__init__(self, *args, **kwargs)
80
81 if 'key' in kwargs and 'key_type' in kwargs and 'local_id' in kwargs:
82 self.key = kwargs['key']
83 self.key_type = kwargs['key_type']
84 self.local_id = kwargs['local_id']
85
86 key = models.TextField()
87 key_type = models.TextField()
88 local_id = models.IntegerField()
89 content = models.TextField(blank=True, null=True)
90
91 def __str__(self):
92 return '%s::%s::%d' % (self.key_type, self.key, self.local_id)
93
94 def to_xml_element(self, element: et.Element):
95 """
96 Exports global id to an XML element.
97 """
98
99 element.set(ATTR_KEY, self.key)
100 element.set(ATTR_KEY_TYPE, self.key_type)
101 element.set(ATTR_LOCAL_ID, str(self.local_id))
102
103 @staticmethod
104 def from_xml_element(element: et.Element):
105 """
106 Parses XML id tag and gets global id from it.
107
108 Arguments:
109 element -- the XML 'id' element
110
111 Returns:
112 global_id -- id itself
113 exists -- True if the global id was taken from database, False if it
114 did not exist and was created.
115 """
116
117 try:
118 return GlobalId.objects.get(key=element.get(ATTR_KEY),
119 key_type=element.get(ATTR_KEY_TYPE),
120 local_id=int(element.get(
121 ATTR_LOCAL_ID))), True
122 except GlobalId.DoesNotExist:
123 return GlobalId(key=element.get(ATTR_KEY),
124 key_type=element.get(ATTR_KEY_TYPE),
125 local_id=int(element.get(ATTR_LOCAL_ID))), False
126
127
128 class Signature(models.Model):
129 class Meta:
130 app_label = 'boards'
131
132 def __init__(self, *args, **kwargs):
133 models.Model.__init__(self, *args, **kwargs)
134
135 if 'key' in kwargs and 'key_type' in kwargs and 'signature' in kwargs:
136 self.key_type = kwargs['key_type']
137 self.key = kwargs['key']
138 self.signature = kwargs['signature']
139
140 key_type = models.TextField()
141 key = models.TextField()
142 signature = models.TextField()
143
144 global_id = models.ForeignKey('GlobalId')
@@ -0,0 +1,61 b''
1 import base64
2 from ecdsa import SigningKey, VerifyingKey, BadSignatureError
3 from django.db import models
4
5 TYPE_ECDSA = 'ecdsa'
6
7 APP_LABEL_BOARDS = 'boards'
8
9
10 class KeyPairManager(models.Manager):
11 def generate_key(self, key_type=TYPE_ECDSA, primary=False):
12 if primary and self.filter(primary=True).exists():
13 raise Exception('There can be only one primary key')
14
15 if key_type == TYPE_ECDSA:
16 private = SigningKey.generate()
17 public = private.get_verifying_key()
18
19 private_key_str = base64.b64encode(private.to_string()).decode()
20 public_key_str = base64.b64encode(public.to_string()).decode()
21
22 return self.create(public_key=public_key_str,
23 private_key=private_key_str,
24 key_type=TYPE_ECDSA, primary=primary)
25 else:
26 raise Exception('Key type not supported')
27
28 def verify(self, signature, string):
29 if signature.key_type == TYPE_ECDSA:
30 public = VerifyingKey.from_string(base64.b64decode(signature.key))
31 signature_byte = base64.b64decode(signature.signature)
32 try:
33 return public.verify(signature_byte, string.encode())
34 except BadSignatureError:
35 return False
36 else:
37 raise Exception('Key type not supported')
38
39 def has_primary(self):
40 return self.filter(primary=True).exists()
41
42
43 class KeyPair(models.Model):
44 class Meta:
45 app_label = APP_LABEL_BOARDS
46
47 objects = KeyPairManager()
48
49 public_key = models.TextField()
50 private_key = models.TextField()
51 key_type = models.TextField()
52 primary = models.BooleanField(default=False)
53
54 def __str__(self):
55 return '%s::%s' % (self.key_type, self.public_key)
56
57 def sign(self, string):
58 private = SigningKey.from_string(base64.b64decode(
59 self.private_key.encode()))
60 signature_byte = private.sign_deterministic(string.encode())
61 return base64.b64encode(signature_byte).decode()
@@ -0,0 +1,89 b''
1 import re
2 from boards.mdx_neboard import get_parser
3
4 from boards.models import Post, GlobalId
5 from boards.models.post import REGEX_NOTIFICATION
6 from boards.models.post import REGEX_REPLY, REGEX_GLOBAL_REPLY
7 from boards.models.user import Notification
8 from django.db.models.signals import post_save, pre_save, pre_delete, \
9 post_delete
10 from django.dispatch import receiver
11 from django.utils import timezone
12
13
14 @receiver(post_save, sender=Post)
15 def connect_replies(instance, **kwargs):
16 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
17 post_id = reply_number.group(1)
18
19 try:
20 referenced_post = Post.objects.get(id=post_id)
21
22 if not referenced_post.referenced_posts.filter(
23 id=instance.id).exists():
24 referenced_post.referenced_posts.add(instance)
25 referenced_post.last_edit_time = instance.pub_time
26 referenced_post.build_refmap()
27 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
28 except Post.DoesNotExist:
29 pass
30
31
32 @receiver(post_save, sender=Post)
33 def connect_global_replies(instance, **kwargs):
34 for reply_number in re.finditer(REGEX_GLOBAL_REPLY, instance.get_raw_text()):
35 key_type = reply_number.group(1)
36 key = reply_number.group(2)
37 local_id = reply_number.group(3)
38
39 try:
40 global_id = GlobalId.objects.get(key_type=key_type, key=key,
41 local_id=local_id)
42 referenced_post = Post.objects.get(global_id=global_id)
43 referenced_post.referenced_posts.add(instance)
44 referenced_post.last_edit_time = instance.pub_time
45 referenced_post.build_refmap()
46 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
47 except (GlobalId.DoesNotExist, Post.DoesNotExist):
48 pass
49
50
51 @receiver(post_save, sender=Post)
52 def connect_notifications(instance, **kwargs):
53 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
54 user_name = reply_number.group(1).lower()
55 Notification.objects.get_or_create(name=user_name, post=instance)
56
57
58 @receiver(pre_save, sender=Post)
59 def preparse_text(instance, **kwargs):
60 instance._text_rendered = get_parser().parse(instance.get_raw_text())
61
62
63 @receiver(pre_delete, sender=Post)
64 def delete_images(instance, **kwargs):
65 for image in instance.images.all():
66 image_refs_count = image.post_images.count()
67 if image_refs_count == 1:
68 image.delete()
69
70
71 @receiver(pre_delete, sender=Post)
72 def delete_attachments(instance, **kwargs):
73 for attachment in instance.attachments.all():
74 attachment_refs_count = attachment.attachment_posts.count()
75 if attachment_refs_count == 1:
76 attachment.delete()
77
78
79 @receiver(post_delete, sender=Post)
80 def update_thread_on_delete(instance, **kwargs):
81 thread = instance.get_thread()
82 thread.last_edit_time = timezone.now()
83 thread.save()
84
85
86 @receiver(post_delete, sender=Post)
87 def delete_global_id(instance, **kwargs):
88 if instance.global_id and instance.global_id.id:
89 instance.global_id.delete()
@@ -0,0 +1,89 b''
1 from base64 import b64encode
2 import logging
3
4 from django.test import TestCase
5 from boards.models import KeyPair, GlobalId, Post, Signature
6 from boards.models.post.sync import SyncManager
7
8 logger = logging.getLogger(__name__)
9
10
11 class KeyTest(TestCase):
12 def test_create_key(self):
13 key = KeyPair.objects.generate_key('ecdsa')
14
15 self.assertIsNotNone(key, 'The key was not created.')
16
17 def test_validation(self):
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
19 message = 'msg'
20 signature_value = key.sign(message)
21
22 signature = Signature(key_type='ecdsa', key=key.public_key,
23 signature=signature_value)
24 valid = KeyPair.objects.verify(signature, message)
25
26 self.assertTrue(valid, 'Message verification failed.')
27
28 def test_primary_constraint(self):
29 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
30
31 with self.assertRaises(Exception):
32 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
33
34 def test_model_id_save(self):
35 model_id = GlobalId(key_type='test', key='test key', local_id='1')
36 model_id.save()
37
38 def test_request_get(self):
39 post = self._create_post_with_key()
40
41 request = GlobalId.objects.generate_request_get([post.global_id])
42 logger.debug(request)
43
44 key = KeyPair.objects.get(primary=True)
45 self.assertTrue('<request type="get" version="1.0">'
46 '<model name="post" version="1.0">'
47 '<id key="%s" local-id="1" type="%s" />'
48 '</model>'
49 '</request>' % (
50 key.public_key,
51 key.key_type,
52 ) in request,
53 'Wrong XML generated for the GET request.')
54
55 def test_response_get(self):
56 post = self._create_post_with_key()
57 reply_post = Post.objects.create_post(title='test_title',
58 text='[post]%d[/post]' % post.id,
59 thread=post.get_thread())
60
61 response = SyncManager.generate_response_get([reply_post])
62 logger.debug(response)
63
64 key = KeyPair.objects.get(primary=True)
65 self.assertTrue('<status>success</status>'
66 '<models>'
67 '<model name="post">'
68 '<content>'
69 '<id key="%s" local-id="%d" type="%s" />'
70 '<title>test_title</title>'
71 '<text>[post]%s[/post]</text>'
72 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
73 '<pub-time>%s</pub-time>'
74 '</content>' % (
75 key.public_key,
76 reply_post.id,
77 key.key_type,
78 str(post.global_id),
79 key.public_key,
80 post.id,
81 key.key_type,
82 str(reply_post.get_pub_time_str()),
83 ) in response,
84 'Wrong XML generated for the GET response.')
85
86 def _create_post_with_key(self):
87 KeyPair.objects.generate_key(primary=True)
88
89 return Post.objects.create_post(title='test_title', text='test_text')
@@ -0,0 +1,102 b''
1 from boards.models import KeyPair, Post, Tag
2 from boards.models.post.sync import SyncManager
3 from boards.tests.mocks import MockRequest
4 from boards.views.sync import response_get
5
6 __author__ = 'neko259'
7
8
9 from django.test import TestCase
10
11
12 class SyncTest(TestCase):
13 def test_get(self):
14 """
15 Forms a GET request of a post and checks the response.
16 """
17
18 key = KeyPair.objects.generate_key(primary=True)
19 tag = Tag.objects.create(name='tag1')
20 post = Post.objects.create_post(title='test_title',
21 text='test_text\rline two',
22 tags=[tag])
23
24 request = MockRequest()
25 request.body = (
26 '<request type="get" version="1.0">'
27 '<model name="post" version="1.0">'
28 '<id key="%s" local-id="%d" type="%s" />'
29 '</model>'
30 '</request>' % (post.global_id.key,
31 post.id,
32 post.global_id.key_type)
33 )
34
35 response = response_get(request).content.decode()
36 self.assertTrue(
37 '<status>success</status>'
38 '<models>'
39 '<model name="post">'
40 '<content>'
41 '<id key="%s" local-id="%d" type="%s" />'
42 '<title>%s</title>'
43 '<text>%s</text>'
44 '<tags><tag>%s</tag></tags>'
45 '<pub-time>%s</pub-time>'
46 '</content>' % (
47 post.global_id.key,
48 post.global_id.local_id,
49 post.global_id.key_type,
50 post.title,
51 post.get_sync_text(),
52 post.get_thread().get_tags().first().name,
53 post.get_pub_time_str(),
54 ) in response,
55 'Wrong response generated for the GET request.')
56
57 post.delete()
58 key.delete()
59
60 KeyPair.objects.generate_key(primary=True)
61
62 SyncManager.parse_response_get(response, None)
63 self.assertEqual(1, Post.objects.count(),
64 'Post was not created from XML response.')
65
66 parsed_post = Post.objects.first()
67 self.assertEqual('tag1',
68 parsed_post.get_thread().get_tags().first().name,
69 'Invalid tag was parsed.')
70
71 SyncManager.parse_response_get(response, None)
72 self.assertEqual(1, Post.objects.count(),
73 'The same post was imported twice.')
74
75 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
76 'Signature was not saved.')
77
78 post = parsed_post
79
80 # Trying to sync the same once more
81 response = response_get(request).content.decode()
82
83 self.assertTrue(
84 '<status>success</status>'
85 '<models>'
86 '<model name="post">'
87 '<content>'
88 '<id key="%s" local-id="%d" type="%s" />'
89 '<title>%s</title>'
90 '<text>%s</text>'
91 '<tags><tag>%s</tag></tags>'
92 '<pub-time>%s</pub-time>'
93 '</content>' % (
94 post.global_id.key,
95 post.global_id.local_id,
96 post.global_id.key_type,
97 post.title,
98 post.get_sync_text(),
99 post.get_thread().get_tags().first().name,
100 post.get_pub_time_str(),
101 ) in response,
102 'Wrong response generated for the GET request.')
@@ -0,0 +1,62 b''
1 import xml.etree.ElementTree as et
2 import xml.dom.minidom
3
4 from django.http import HttpResponse, Http404
5 from boards.models import GlobalId, Post
6 from boards.models.post.sync import SyncManager
7
8
9 def response_pull(request):
10 request_xml = request.body
11
12 if request_xml is None:
13 return HttpResponse(content='Use the API')
14
15 response_xml = SyncManager.generate_response_pull()
16
17 return HttpResponse(content=response_xml)
18
19
20 def response_get(request):
21 """
22 Processes a GET request with post ID list and returns the posts XML list.
23 Request should contain an 'xml' post attribute with the actual request XML.
24 """
25
26 request_xml = request.body
27
28 if request_xml is None:
29 return HttpResponse(content='Use the API')
30
31 posts = []
32
33 root_tag = et.fromstring(request_xml)
34 model_tag = root_tag[0]
35 for id_tag in model_tag:
36 global_id, exists = GlobalId.from_xml_element(id_tag)
37 if exists:
38 posts.append(Post.objects.get(global_id=global_id))
39
40 response_xml = SyncManager.generate_response_get(posts)
41
42 return HttpResponse(content=response_xml)
43
44
45 def get_post_sync_data(request, post_id):
46 try:
47 post = Post.objects.get(id=post_id)
48 except Post.DoesNotExist:
49 raise Http404()
50
51 xml_str = SyncManager.generate_response_get([post])
52
53 xml_repr = xml.dom.minidom.parseString(xml_str)
54 xml_repr = xml_repr.toprettyxml()
55
56 content = '=Global ID=\n%s\n\n=XML=\n%s' \
57 % (post.global_id, xml_repr)
58
59 return HttpResponse(
60 content_type='text/plain',
61 content=content,
62 ) No newline at end of file
@@ -0,0 +1,3 b''
1 #! /usr/bin/env sh
2
3 python3 manage.py runserver [::]:8000
@@ -0,0 +1,3 b''
1 #! /usr/bin/env sh
2
3 python3 manage.py test
@@ -0,0 +1,203 b''
1 # 0 Title #
2
3 DIP-1 Common protocol description
4
5 # 1 Intro #
6
7 This document describes the Data Interchange Protocol (DIP), designed to
8 exchange filtered data that can be stored as a graph structure between
9 network nodes.
10
11 # 2 Purpose #
12
13 This protocol will be used to share the models (originally imageboard posts)
14 across multiple servers. The main differnce of this protocol is that the node
15 can specify what models it wants to get and from whom. The nodes can get
16 models from a specific server, or from all except some specific servers. Also
17 the models can be filtered by timestamps or tags.
18
19 # 3 Protocol description #
20
21 The node requests other node's changes list since some time (since epoch if
22 this is the start). The other node sends a list of post ids or posts in the
23 XML format.
24
25 Protocol version is the version of the sync api. Model version is the version
26 of data models. If at least one of them is different, the sync cannot be
27 performed.
28
29 The node signs the data with its keys. The receiving node saves the key at the
30 first sync and checks it every time. If the key has changed, the info won't be
31 saved from the node (or the node id must be changed). A model can be signed
32 with several keys but at least one of them must be the same as in the global
33 ID to verify the sender.
34
35 Each node can have several keys. Nodes can have shared keys to serve as a pool
36 (several nodes with the same key).
37
38 Each post has an ID in the unique format: key-type::key::local-id
39
40 All requests pass a request type, protocol and model versions, and a list of
41 optional arguments used for filtering.
42
43 Each request has its own version. Version consists of 2 numbers: first is
44 incompatible version (1.3 and 2.0 are not compatible and must not be in sync)
45 and the second one is minor and compatible (for example, new optional field
46 is added which will be igroned by those who don't support it yet).
47
48 Post edits and reflinks are not saved to the sync model. The replied post ID
49 can be got from the post text, and reflinks can be computed when loading
50 posts. The edit time is not saved because a foreign post can be 'edited' (new
51 replies are added) but the signature must not change (so we can't update the
52 content). The inner posts can be edited, and the signature will change then
53 but the local-id won't, so the other node can detect that and replace the post
54 instead of adding a new one.
55
56 ## 3.1 Requests ##
57
58 There is no constraint on how the server should calculate the request. The
59 server can return any information by any filter and the requesting node is
60 responsible for validating it.
61
62 The server is required to return the status of request. See 3.2 for details.
63
64 ### 3.1.1 pull ###
65
66 "pull" request gets the desired model id list by the given filter (e.g. thread, tags,
67 author)
68
69 Sample request is as follows:
70
71 <?xml version="1.1" encoding="UTF-8" ?>
72 <request version="1.0" type="pull">
73 <model version="1.0" name="post">
74 <timestamp_from>0</timestamp_from>
75 <timestamp_to>0</timestamp_to>
76 <tags>
77 <tag>tag1</tag>
78 </tags>
79 <sender>
80 <allow>
81 <key>abcehy3h9t</key>
82 <key>ehoehyoe</key>
83 </allow>
84 <!-- There can be only allow block (all other are denied) or deny block (all other are allowed) -->
85 </sender>
86 </model>
87 </request>
88
89 Under the <model> tag there are filters. Filters for the "post" model can
90 be found in DIP-2.
91
92 Sample response:
93
94 <?xml version="1.1" encoding="UTF-8" ?>
95 <response>
96 <status>success</status>
97 <models>
98 <id key="id1" type="ecdsa" local-id="1" />
99 <id key="id1" type="ecdsa" local-id="2" />
100 <id key="id2" type="ecdsa" local-id="1" />
101 <id key="id2" type="ecdsa" local-id="5" />
102 </models>
103 </response>
104
105 ### 3.1.2 get ###
106
107 "get" gets models by id list.
108
109 Sample request:
110
111 <?xml version="1.1" encoding="UTF-8" ?>
112 <request version="1.0" type="get">
113 <model version="1.0" name="post">
114 <id key="id1" type="ecdsa" local-id="1" />
115 <id key="id1" type="ecdsa" local-id="2" />
116 </model>
117 </request>
118
119 Id consists of a key, key type and local id. This key is used for signing and
120 validating of data in the model content.
121
122 Sample response:
123
124 <?xml version="1.1" encoding="UTF-8" ?>
125 <response>
126 <!--
127 Valid statuses are 'success' and 'error'.
128 -->
129 <status>success</status>
130 <models>
131 <model name="post">
132 <!--
133 Content tag is the data that is signed by signatures and must
134 not be changed for the post from other node.
135 -->
136 <content>
137 <id key="id1" type="ecdsa" local-id="1" />
138 <title>13</title>
139 <text>Thirteen</text>
140 <thread><id key="id1" type="ecdsa" local-id="2" /></thread>
141 <pub-time>12</pub-time>
142 <!--
143 Images are saved as attachments and included in the
144 signature.
145 -->
146 <attachments>
147 <attachment mimetype="image/png">TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5I</attachment>
148 </attachments>
149 </content>
150 <!--
151 There can be several signatures for one model. At least one
152 signature must be made with the key used in global ID.
153 -->
154 <signatures>
155 <signature key="id1" type="ecdsa" value="dhefhtreh" />
156 <signature key="id45" type="ecdsa" value="dsgfgdhefhtreh" />
157 </signatures>
158 <attachment-refs>
159 <attachment-ref ref="TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0"
160 url="/media/images/12345.png" />
161 </attachment-refs>
162 </model>
163 <model name="post">
164 <content>
165 <id key="id1" type="ecdsa" local-id="id2" />
166 <title>13</title>
167 <text>Thirteen</text>
168 <pub-time>12</pub-time>
169 <edit-time>13</edit-time>
170 <tags>
171 <tag>tag1</tag>
172 </tags>
173 </content>
174 <signatures>
175 <signature key="id2" type="ecdsa" value="dehdfh" />
176 </signatures>
177 </model>
178 </models>
179 </response>
180
181 ### 3.1.3 put ###
182
183 "put" gives a model to the given node (you have no guarantee the node takes
184 it, consider you are just advising the node to take your post). This request
185 type is useful in pool where all the nodes try to duplicate all of their data
186 across the pool.
187
188 ## 3.2 Responses ##
189
190 ### 3.2.1 "not supported" ###
191
192 If the request if completely not supported, a "not supported" status will be
193 returned.
194
195 ### 3.2.2 "success" ###
196
197 "success" status means the request was processed and the result is returned.
198
199 ### 3.2.3 "error" ###
200
201 If the server knows for sure that the operation cannot be processed, it sends
202 the "error" status. Additional tags describing the error may be <description>
203 and <stack>.
@@ -0,0 +1,29 b''
1 # 0 Title #
2
3 "post" model reference
4
5 # 1 Description #
6
7 "post" is a model that defines an imageboard message, or post.
8
9 # 2 Fields #
10
11 # 2.1 Mandatory fields #
12
13 * title -- text field.
14 * text -- text field.
15 * pub-time -- timestamp (TBD: Define format).
16
17 # 2.2 Optional fields #
18
19 * attachments -- defines attachments such as images.
20 * attachment -- contains and attachment or link to attachment to be downloaded
21 manually. Required attributes are mimetype and name.
22
23 This field is used for the opening post (thread):
24
25 * tags -- text tag name list.
26
27 This field is used for non-opening post:
28
29 * thread -- ID of a post model that this post is related to.
@@ -0,0 +1,1 b''
1 default_app_config = 'boards.apps.BoardsAppConfig' No newline at end of file
@@ -1,114 +1,127 b''
1 1 from django.contrib import admin
2 2 from django.utils.translation import ugettext_lazy as _
3 3 from django.core.urlresolvers import reverse
4 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage
4 from boards.models import Post, Tag, Ban, Thread, Banner, PostImage, KeyPair, GlobalId
5 5
6 6
7 7 @admin.register(Post)
8 8 class PostAdmin(admin.ModelAdmin):
9 9
10 10 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images')
11 11 list_filter = ('pub_time',)
12 12 search_fields = ('id', 'title', 'text', 'poster_ip')
13 13 exclude = ('referenced_posts', 'refmap', 'images')
14 14 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
15 15 'attachments', 'uid', 'url', 'pub_time', 'opening')
16 16
17 17 def ban_poster(self, request, queryset):
18 18 bans = 0
19 19 for post in queryset:
20 20 poster_ip = post.poster_ip
21 21 ban, created = Ban.objects.get_or_create(ip=poster_ip)
22 22 if created:
23 23 bans += 1
24 24 self.message_user(request, _('{} posters were banned').format(bans))
25 25
26 26 def ban_with_hiding(self, request, queryset):
27 27 bans = 0
28 28 hidden = 0
29 29 for post in queryset:
30 30 poster_ip = post.poster_ip
31 31 ban, created = Ban.objects.get_or_create(ip=poster_ip)
32 32 if created:
33 33 bans += 1
34 34 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
35 35 hidden += posts.count()
36 36 posts.update(hidden=True)
37 37 self.message_user(request, _('{} posters were banned, {} messages were hidden').format(bans, hidden))
38 38
39 39 def linked_images(self, obj: Post):
40 40 images = obj.images.all()
41 41 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
42 42 reverse('admin:%s_%s_change' % (image._meta.app_label,
43 43 image._meta.model_name),
44 44 args=[image.id]), image.image.url_200x150) for image in images]
45 45 return ', '.join(image_urls)
46 46 linked_images.allow_tags = True
47 47
48 48
49 49 actions = ['ban_poster', 'ban_with_hiding']
50 50
51 51
52 52 @admin.register(Tag)
53 53 class TagAdmin(admin.ModelAdmin):
54 54
55 55 def thread_count(self, obj: Tag) -> int:
56 56 return obj.get_thread_count()
57 57
58 58 def display_children(self, obj: Tag):
59 59 return ', '.join([str(child) for child in obj.get_children().all()])
60 60
61 61 def save_model(self, request, obj, form, change):
62 62 super().save_model(request, obj, form, change)
63 63 for thread in obj.get_threads().all():
64 64 thread.refresh_tags()
65
66 65 list_display = ('name', 'thread_count', 'display_children')
67 66 search_fields = ('name',)
68 67
69 68
70 69 @admin.register(Thread)
71 70 class ThreadAdmin(admin.ModelAdmin):
72 71
73 72 def title(self, obj: Thread) -> str:
74 73 return obj.get_opening_post().get_title()
75 74
76 75 def reply_count(self, obj: Thread) -> int:
77 76 return obj.get_reply_count()
78 77
79 78 def ip(self, obj: Thread):
80 79 return obj.get_opening_post().poster_ip
81 80
82 81 def display_tags(self, obj: Thread):
83 82 return ', '.join([str(tag) for tag in obj.get_tags().all()])
84 83
85 84 def op(self, obj: Thread):
86 85 return obj.get_opening_post_id()
87 86
88 87 # Save parent tags when editing tags
89 88 def save_related(self, request, form, formsets, change):
90 89 super().save_related(request, form, formsets, change)
91 90 form.instance.refresh_tags()
92
93 91 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
94 92 'display_tags')
95 93 list_filter = ('bump_time', 'status')
96 94 search_fields = ('id', 'title')
97 95 filter_horizontal = ('tags',)
98 96
99 97
98 @admin.register(KeyPair)
99 class KeyPairAdmin(admin.ModelAdmin):
100 list_display = ('public_key', 'primary')
101 list_filter = ('primary',)
102 search_fields = ('public_key',)
103
104
100 105 @admin.register(Ban)
101 106 class BanAdmin(admin.ModelAdmin):
102 107 list_display = ('ip', 'can_read')
103 108 list_filter = ('can_read',)
104 109 search_fields = ('ip',)
105 110
106 111
107 112 @admin.register(Banner)
108 113 class BannerAdmin(admin.ModelAdmin):
109 114 list_display = ('title', 'text')
110 115
111 116
112 117 @admin.register(PostImage)
113 118 class PostImageAdmin(admin.ModelAdmin):
114 119 search_fields = ('alias',)
120
121
122 @admin.register(GlobalId)
123 class GlobalIdAdmin(admin.ModelAdmin):
124 def is_linked(self, obj):
125 return Post.objects.filter(global_id=obj).exists()
126
127 list_display = ('__str__', 'is_linked',) No newline at end of file
@@ -1,469 +1,464 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4 import logging
5 5
6 6 import pytz
7 7
8 8 from django import forms
9 9 from django.core.files.uploadedfile import SimpleUploadedFile
10 10 from django.core.exceptions import ObjectDoesNotExist
11 11 from django.forms.utils import ErrorList
12 12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
13 13 from django.utils import timezone
14 14
15 15 from boards.abstracts.settingsmanager import get_settings_manager
16 16 from boards.abstracts.attachment_alias import get_image_by_alias
17 17 from boards.mdx_neboard import formatters
18 from boards.models.attachment.downloaders import Downloader
18 from boards.models.attachment.downloaders import download
19 19 from boards.models.post import TITLE_MAX_LENGTH
20 20 from boards.models import Tag, Post
21 21 from boards.utils import validate_file_size, get_file_mimetype, \
22 22 FILE_EXTENSION_DELIMITER
23 23 from neboard import settings
24 24 import boards.settings as board_settings
25 25 import neboard
26 26
27 27 POW_HASH_LENGTH = 16
28 28 POW_LIFE_MINUTES = 5
29 29
30 30 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
31 31 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
32 32
33 33 VETERAN_POSTING_DELAY = 5
34 34
35 35 ATTRIBUTE_PLACEHOLDER = 'placeholder'
36 36 ATTRIBUTE_ROWS = 'rows'
37 37
38 38 LAST_POST_TIME = 'last_post_time'
39 39 LAST_LOGIN_TIME = 'last_login_time'
40 40 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
41 41 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
42 42
43 43 LABEL_TITLE = _('Title')
44 44 LABEL_TEXT = _('Text')
45 45 LABEL_TAG = _('Tag')
46 46 LABEL_SEARCH = _('Search')
47 47
48 48 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
49 49 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
50 50
51 51 TAG_MAX_LENGTH = 20
52 52
53 53 TEXTAREA_ROWS = 4
54 54
55 55 TRIPCODE_DELIM = '#'
56 56
57 57 # TODO Maybe this may be converted into the database table?
58 58 MIMETYPE_EXTENSIONS = {
59 59 'image/jpeg': 'jpeg',
60 60 'image/png': 'png',
61 61 'image/gif': 'gif',
62 62 'video/webm': 'webm',
63 63 'application/pdf': 'pdf',
64 64 'x-diff': 'diff',
65 65 'image/svg+xml': 'svg',
66 66 'application/x-shockwave-flash': 'swf',
67 67 'image/x-ms-bmp': 'bmp',
68 68 'image/bmp': 'bmp',
69 69 }
70 70
71 71
72 72 def get_timezones():
73 73 timezones = []
74 74 for tz in pytz.common_timezones:
75 75 timezones.append((tz, tz),)
76 76 return timezones
77 77
78 78
79 79 class FormatPanel(forms.Textarea):
80 80 """
81 81 Panel for text formatting. Consists of buttons to add different tags to the
82 82 form text area.
83 83 """
84 84
85 85 def render(self, name, value, attrs=None):
86 86 output = '<div id="mark-panel">'
87 87 for formatter in formatters:
88 88 output += '<span class="mark_btn"' + \
89 89 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
90 90 '\', \'' + formatter.format_right + '\')">' + \
91 91 formatter.preview_left + formatter.name + \
92 92 formatter.preview_right + '</span>'
93 93
94 94 output += '</div>'
95 95 output += super(FormatPanel, self).render(name, value, attrs=attrs)
96 96
97 97 return output
98 98
99 99
100 100 class PlainErrorList(ErrorList):
101 101 def __unicode__(self):
102 102 return self.as_text()
103 103
104 104 def as_text(self):
105 105 return ''.join(['(!) %s ' % e for e in self])
106 106
107 107
108 108 class NeboardForm(forms.Form):
109 109 """
110 110 Form with neboard-specific formatting.
111 111 """
112 112
113 113 def as_div(self):
114 114 """
115 115 Returns this form rendered as HTML <as_div>s.
116 116 """
117 117
118 118 return self._html_output(
119 119 # TODO Do not show hidden rows in the list here
120 120 normal_row='<div class="form-row">'
121 121 '<div class="form-label">'
122 122 '%(label)s'
123 123 '</div>'
124 124 '<div class="form-input">'
125 125 '%(field)s'
126 126 '</div>'
127 127 '</div>'
128 128 '<div class="form-row">'
129 129 '%(help_text)s'
130 130 '</div>',
131 131 error_row='<div class="form-row">'
132 132 '<div class="form-label"></div>'
133 133 '<div class="form-errors">%s</div>'
134 134 '</div>',
135 135 row_ender='</div>',
136 136 help_text_html='%s',
137 137 errors_on_separate_row=True)
138 138
139 139 def as_json_errors(self):
140 140 errors = []
141 141
142 142 for name, field in list(self.fields.items()):
143 143 if self[name].errors:
144 144 errors.append({
145 145 'field': name,
146 146 'errors': self[name].errors.as_text(),
147 147 })
148 148
149 149 return errors
150 150
151 151
152 152 class PostForm(NeboardForm):
153 153
154 154 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
155 155 label=LABEL_TITLE,
156 156 widget=forms.TextInput(
157 157 attrs={ATTRIBUTE_PLACEHOLDER:
158 158 'test#tripcode'}))
159 159 text = forms.CharField(
160 160 widget=FormatPanel(attrs={
161 161 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
162 162 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
163 163 }),
164 164 required=False, label=LABEL_TEXT)
165 165 file = forms.FileField(required=False, label=_('File'),
166 166 widget=forms.ClearableFileInput(
167 167 attrs={'accept': 'file/*'}))
168 168 file_url = forms.CharField(required=False, label=_('File URL'),
169 169 widget=forms.TextInput(
170 170 attrs={ATTRIBUTE_PLACEHOLDER:
171 171 'http://example.com/image.png'}))
172 172
173 173 # This field is for spam prevention only
174 174 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
175 175 widget=forms.TextInput(attrs={
176 176 'class': 'form-email'}))
177 177 threads = forms.CharField(required=False, label=_('Additional threads'),
178 178 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
179 179 '123 456 789'}))
180 180
181 181 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
182 182 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
183 183 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
184 184
185 185 session = None
186 186 need_to_ban = False
187 187 image = None
188 188
189 189 def _update_file_extension(self, file):
190 190 if file:
191 191 mimetype = get_file_mimetype(file)
192 192 extension = MIMETYPE_EXTENSIONS.get(mimetype)
193 193 if extension:
194 194 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
195 195 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
196 196
197 197 file.name = new_filename
198 198 else:
199 199 logger = logging.getLogger('boards.forms.extension')
200 200
201 201 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
202 202
203 203 def clean_title(self):
204 204 title = self.cleaned_data['title']
205 205 if title:
206 206 if len(title) > TITLE_MAX_LENGTH:
207 207 raise forms.ValidationError(_('Title must have less than %s '
208 208 'characters') %
209 209 str(TITLE_MAX_LENGTH))
210 210 return title
211 211
212 212 def clean_text(self):
213 213 text = self.cleaned_data['text'].strip()
214 214 if text:
215 215 max_length = board_settings.get_int('Forms', 'MaxTextLength')
216 216 if len(text) > max_length:
217 217 raise forms.ValidationError(_('Text must have less than %s '
218 218 'characters') % str(max_length))
219 219 return text
220 220
221 221 def clean_file(self):
222 222 file = self.cleaned_data['file']
223 223
224 224 if file:
225 225 validate_file_size(file.size)
226 226 self._update_file_extension(file)
227 227
228 228 return file
229 229
230 230 def clean_file_url(self):
231 231 url = self.cleaned_data['file_url']
232 232
233 233 file = None
234 234
235 235 if url:
236 236 file = get_image_by_alias(url, self.session)
237 237 self.image = file
238 238
239 239 if file is not None:
240 240 return
241 241
242 242 if file is None:
243 243 file = self._get_file_from_url(url)
244 244 if not file:
245 245 raise forms.ValidationError(_('Invalid URL'))
246 246 else:
247 247 validate_file_size(file.size)
248 248 self._update_file_extension(file)
249 249
250 250 return file
251 251
252 252 def clean_threads(self):
253 253 threads_str = self.cleaned_data['threads']
254 254
255 255 if len(threads_str) > 0:
256 256 threads_id_list = threads_str.split(' ')
257 257
258 258 threads = list()
259 259
260 260 for thread_id in threads_id_list:
261 261 try:
262 262 thread = Post.objects.get(id=int(thread_id))
263 263 if not thread.is_opening() or thread.get_thread().is_archived():
264 264 raise ObjectDoesNotExist()
265 265 threads.append(thread)
266 266 except (ObjectDoesNotExist, ValueError):
267 267 raise forms.ValidationError(_('Invalid additional thread list'))
268 268
269 269 return threads
270 270
271 271 def clean(self):
272 272 cleaned_data = super(PostForm, self).clean()
273 273
274 274 if cleaned_data['email']:
275 275 self.need_to_ban = True
276 276 raise forms.ValidationError('A human cannot enter a hidden field')
277 277
278 278 if not self.errors:
279 279 self._clean_text_file()
280 280
281 281 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
282 282
283 283 settings_manager = get_settings_manager(self)
284 284 if not self.errors and limit_speed and not settings_manager.get_setting('confirmed_user'):
285 285 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
286 286 if pow_difficulty > 0:
287 287 # Limit only first post
288 288 if cleaned_data['timestamp'] \
289 289 and cleaned_data['iteration'] and cleaned_data['guess'] \
290 290 and not settings_manager.get_setting('confirmed_user'):
291 291 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
292 292 else:
293 293 # Limit every post
294 294 self._validate_posting_speed()
295 295 settings_manager.set_setting('confirmed_user', True)
296 296
297 297
298 298 return cleaned_data
299 299
300 300 def get_file(self):
301 301 """
302 302 Gets file from form or URL.
303 303 """
304 304
305 305 file = self.cleaned_data['file']
306 306 return file or self.cleaned_data['file_url']
307 307
308 308 def get_tripcode(self):
309 309 title = self.cleaned_data['title']
310 310 if title is not None and TRIPCODE_DELIM in title:
311 311 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
312 312 tripcode = hashlib.md5(code.encode()).hexdigest()
313 313 else:
314 314 tripcode = ''
315 315 return tripcode
316 316
317 317 def get_title(self):
318 318 title = self.cleaned_data['title']
319 319 if title is not None and TRIPCODE_DELIM in title:
320 320 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
321 321 else:
322 322 return title
323 323
324 324 def get_images(self):
325 325 if self.image:
326 326 return [self.image]
327 327 else:
328 328 return []
329 329
330 330 def _clean_text_file(self):
331 331 text = self.cleaned_data.get('text')
332 332 file = self.get_file()
333 333 images = self.get_images()
334 334
335 335 if (not text) and (not file) and len(images) == 0:
336 336 error_message = _('Either text or file must be entered.')
337 337 self._errors['text'] = self.error_class([error_message])
338 338
339 339 def _validate_posting_speed(self):
340 340 can_post = True
341 341
342 342 posting_delay = settings.POSTING_DELAY
343 343
344 344 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
345 345 now = time.time()
346 346
347 347 current_delay = 0
348 348
349 349 if LAST_POST_TIME not in self.session:
350 350 self.session[LAST_POST_TIME] = now
351 351
352 352 need_delay = True
353 353 else:
354 354 last_post_time = self.session.get(LAST_POST_TIME)
355 355 current_delay = int(now - last_post_time)
356 356
357 357 need_delay = current_delay < posting_delay
358 358
359 359 if need_delay:
360 360 delay = posting_delay - current_delay
361 361 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
362 362 delay) % {'delay': delay}
363 363 self._errors['text'] = self.error_class([error_message])
364 364
365 365 can_post = False
366 366
367 367 if can_post:
368 368 self.session[LAST_POST_TIME] = now
369 369
370 370 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
371 371 """
372 372 Gets an file file from URL.
373 373 """
374 374
375 375 img_temp = None
376 376
377 377 try:
378 for downloader in Downloader.__subclasses__():
379 if downloader.handles(url):
380 return downloader.download(url)
381 # If nobody of the specific downloaders handles this, use generic
382 # one
383 return Downloader.download(url)
378 download(url)
384 379 except forms.ValidationError as e:
385 380 raise e
386 381 except Exception as e:
387 382 raise forms.ValidationError(e)
388 383
389 384 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
390 385 post_time = timezone.datetime.fromtimestamp(
391 386 int(timestamp[:-3]), tz=timezone.get_current_timezone())
392 387
393 388 payload = timestamp + message.replace('\r\n', '\n')
394 389 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
395 390 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
396 391 if len(target) < POW_HASH_LENGTH:
397 392 target = '0' * (POW_HASH_LENGTH - len(target)) + target
398 393
399 394 computed_guess = hashlib.sha256((payload + iteration).encode())\
400 395 .hexdigest()[0:POW_HASH_LENGTH]
401 396 if guess != computed_guess or guess > target:
402 397 self._errors['text'] = self.error_class(
403 398 [_('Invalid PoW.')])
404 399
405 400
406 401
407 402 class ThreadForm(PostForm):
408 403
409 404 tags = forms.CharField(
410 405 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
411 406 max_length=100, label=_('Tags'), required=True)
412 407 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
413 408
414 409 def clean_tags(self):
415 410 tags = self.cleaned_data['tags'].strip()
416 411
417 412 if not tags or not REGEX_TAGS.match(tags):
418 413 raise forms.ValidationError(
419 414 _('Inappropriate characters in tags.'))
420 415
421 416 required_tag_exists = False
422 417 tag_set = set()
423 418 for tag_string in tags.split():
424 419 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
425 420 tag_set.add(tag)
426 421
427 422 # If this is a new tag, don't check for its parents because nobody
428 423 # added them yet
429 424 if not created:
430 425 tag_set |= set(tag.get_all_parents())
431 426
432 427 for tag in tag_set:
433 428 if tag.required:
434 429 required_tag_exists = True
435 430 break
436 431
437 432 if not required_tag_exists:
438 433 raise forms.ValidationError(
439 434 _('Need at least one section.'))
440 435
441 436 return tag_set
442 437
443 438 def clean(self):
444 439 cleaned_data = super(ThreadForm, self).clean()
445 440
446 441 return cleaned_data
447 442
448 443 def is_monochrome(self):
449 444 return self.cleaned_data['monochrome']
450 445
451 446
452 447 class SettingsForm(NeboardForm):
453 448
454 449 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
455 450 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
456 451 username = forms.CharField(label=_('User name'), required=False)
457 452 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
458 453
459 454 def clean_username(self):
460 455 username = self.cleaned_data['username']
461 456
462 457 if username and not REGEX_USERNAMES.match(username):
463 458 raise forms.ValidationError(_('Inappropriate characters.'))
464 459
465 460 return username
466 461
467 462
468 463 class SearchForm(NeboardForm):
469 464 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,16 +1,15 b''
1 1 from django.core.management import BaseCommand
2 2 from django.db import transaction
3 3
4 4 from boards.models import Post
5 from boards.models.post import NO_IP
6
5 from boards.models.post.manager import NO_IP
7 6
8 7 __author__ = 'neko259'
9 8
10 9
11 10 class Command(BaseCommand):
12 11 help = 'Removes user and IP data from all posts'
13 12
14 13 @transaction.atomic
15 14 def handle(self, *args, **options):
16 15 Post.objects.all().update(poster_ip=NO_IP) No newline at end of file
@@ -1,244 +1,254 b''
1 1 # coding=utf-8
2 2
3 3 import re
4 4 import random
5 5 import bbcode
6 6
7 7 from urllib.parse import unquote
8 8
9 9 from django.core.exceptions import ObjectDoesNotExist
10 10 from django.core.urlresolvers import reverse
11 11
12 12 import boards
13 13
14 14
15 15 __author__ = 'neko259'
16 16
17 17
18 18 REFLINK_PATTERN = re.compile(r'^\d+$')
19 GLOBAL_REFLINK_PATTERN = re.compile(r'(\w+)::([^:]+)::(\d+)')
19 20 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
20 21 ONE_NEWLINE = '\n'
21 22 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
22 23 LINE_BREAK_HTML = '<div class="br"></div>'
23 24 SPOILER_SPACE = '&nbsp;'
24 25
25 26 MAX_SPOILER_MULTIPLIER = 2
26 27
27 28
28 29 class TextFormatter():
29 30 """
30 31 An interface for formatter that can be used in the text format panel
31 32 """
32 33
33 34 def __init__(self):
34 35 pass
35 36
36 37 name = ''
37 38
38 39 # Left and right tags for the button preview
39 40 preview_left = ''
40 41 preview_right = ''
41 42
42 43 # Left and right characters for the textarea input
43 44 format_left = ''
44 45 format_right = ''
45 46
46 47
47 48 class AutolinkPattern():
48 49 def handleMatch(self, m):
49 50 link_element = etree.Element('a')
50 51 href = m.group(2)
51 52 link_element.set('href', href)
52 53 link_element.text = href
53 54
54 55 return link_element
55 56
56 57
57 58 class QuotePattern(TextFormatter):
58 59 name = '>q'
59 60 preview_left = '<span class="quote">'
60 61 preview_right = '</span>'
61 62
62 63 format_left = '[quote]'
63 64 format_right = '[/quote]'
64 65
65 66
66 67 class SpoilerPattern(TextFormatter):
67 68 name = 'spoiler'
68 69 preview_left = '<span class="spoiler">'
69 70 preview_right = '</span>'
70 71
71 72 format_left = '[spoiler]'
72 73 format_right = '[/spoiler]'
73 74
74 75
75 76 class CommentPattern(TextFormatter):
76 77 name = ''
77 78 preview_left = '<span class="comment">// '
78 79 preview_right = '</span>'
79 80
80 81 format_left = '[comment]'
81 82 format_right = '[/comment]'
82 83
83 84
84 85 # TODO Use <s> tag here
85 86 class StrikeThroughPattern(TextFormatter):
86 87 name = 's'
87 88 preview_left = '<span class="strikethrough">'
88 89 preview_right = '</span>'
89 90
90 91 format_left = '[s]'
91 92 format_right = '[/s]'
92 93
93 94
94 95 class ItalicPattern(TextFormatter):
95 96 name = 'i'
96 97 preview_left = '<i>'
97 98 preview_right = '</i>'
98 99
99 100 format_left = '[i]'
100 101 format_right = '[/i]'
101 102
102 103
103 104 class BoldPattern(TextFormatter):
104 105 name = 'b'
105 106 preview_left = '<b>'
106 107 preview_right = '</b>'
107 108
108 109 format_left = '[b]'
109 110 format_right = '[/b]'
110 111
111 112
112 113 class CodePattern(TextFormatter):
113 114 name = 'code'
114 115 preview_left = '<code>'
115 116 preview_right = '</code>'
116 117
117 118 format_left = '[code]'
118 119 format_right = '[/code]'
119 120
120 121
121 122 def render_reflink(tag_name, value, options, parent, context):
122 123 result = '>>%s' % value
123 124
125 post = None
124 126 if REFLINK_PATTERN.match(value):
125 127 post_id = int(value)
126 128
127 129 try:
128 130 post = boards.models.Post.objects.get(id=post_id)
129 131
130 result = post.get_link_view()
131 132 except ObjectDoesNotExist:
132 133 pass
134 elif GLOBAL_REFLINK_PATTERN.match(value):
135 match = GLOBAL_REFLINK_PATTERN.search(value)
136 try:
137 global_id = boards.models.GlobalId.objects.get(
138 key_type=match.group(1), key=match.group(2),
139 local_id=match.group(3))
140 post = global_id.post
141 except ObjectDoesNotExist:
142 pass
143
144 if post is not None:
145 result = post.get_link_view()
133 146
134 147 return result
135 148
136 149
137 150 def render_quote(tag_name, value, options, parent, context):
138 151 source = ''
139 152 if 'source' in options:
140 153 source = options['source']
141 154 elif 'quote' in options:
142 155 source = options['quote']
143 156
144 157 if source:
145 158 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
146 159 else:
147 160 # Insert a ">" at the start of every line
148 161 result = '<span class="quote">&gt;{}</span>'.format(
149 162 value.replace(LINE_BREAK_HTML,
150 163 '{}&gt;'.format(LINE_BREAK_HTML)))
151 164
152 165 return result
153 166
154 167
155 168 def render_notification(tag_name, value, options, parent, content):
156 169 username = value.lower()
157 170
158 171 return '<a href="{}" class="user-cast">@{}</a>'.format(
159 172 reverse('notifications', kwargs={'username': username}), username)
160 173
161 174
162 175 def render_tag(tag_name, value, options, parent, context):
163 176 tag_name = value.lower()
164 177
165 178 try:
166 179 url = boards.models.Tag.objects.get(name=tag_name).get_view()
167 180 except ObjectDoesNotExist:
168 181 url = tag_name
169 182
170 183 return url
171 184
172 185
173 186 def render_spoiler(tag_name, value, options, parent, context):
174 187 text_len = len(value)
175 188 space_count = random.randint(0, text_len * MAX_SPOILER_MULTIPLIER)
176 189 side_spaces = SPOILER_SPACE * (space_count // 2)
177 190 return '<span class="spoiler">{}{}{}</span>'.format(side_spaces, value,
178 191 side_spaces)
179 192
180 return quote_element
181
182
183 193
184 194 formatters = [
185 195 QuotePattern,
186 196 SpoilerPattern,
187 197 ItalicPattern,
188 198 BoldPattern,
189 199 CommentPattern,
190 200 StrikeThroughPattern,
191 201 CodePattern,
192 202 ]
193 203
194 204
195 205 PREPARSE_PATTERNS = {
196 206 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
197 207 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
198 208 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
199 209 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
200 210 }
201 211
202 212
203 213 class Parser:
204 214 def __init__(self):
205 215 # The newline hack is added because br's margin does not work in all
206 216 # browsers except firefox, when the div's does.
207 217 self.parser = bbcode.Parser(newline=LINE_BREAK_HTML)
208 218
209 219 self.parser.add_formatter('post', render_reflink, strip=True)
210 220 self.parser.add_formatter('quote', render_quote, strip=True)
211 221 self.parser.add_formatter('user', render_notification, strip=True)
212 222 self.parser.add_formatter('tag', render_tag, strip=True)
213 223 self.parser.add_formatter('spoiler', render_spoiler, strip=True)
214 224 self.parser.add_simple_formatter(
215 225 'comment', '<span class="comment">//%(value)s</span>')
216 226 self.parser.add_simple_formatter(
217 227 's', '<span class="strikethrough">%(value)s</span>')
218 228 # TODO Why not use built-in tag?
219 229 self.parser.add_simple_formatter('code',
220 230 '<pre><code>%(value)s</pre></code>',
221 231 render_embedded=False)
222 232
223 233 def preparse(self, text):
224 234 """
225 235 Performs manual parsing before the bbcode parser is used.
226 236 Preparsed text is saved as raw and the text before preparsing is lost.
227 237 """
228 238 new_text = MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
229 239
230 240 for key, value in PREPARSE_PATTERNS.items():
231 241 new_text = re.sub(key, value, new_text, flags=re.MULTILINE)
232 242
233 243 for link in REGEX_URL.findall(text):
234 244 new_text = new_text.replace(link, unquote(link))
235 245
236 246 return new_text
237 247
238 248 def parse(self, text):
239 249 return self.parser.format(text)
240 250
241 251
242 252 parser = Parser()
243 253 def get_parser():
244 254 return parser
@@ -1,12 +1,14 b''
1 1 STATUS_ACTIVE = 'active'
2 2 STATUS_BUMPLIMIT = 'bumplimit'
3 3 STATUS_ARCHIVE = 'archived'
4 4
5 5
6 from boards.models.signature import GlobalId, Signature
7 from boards.models.sync_key import KeyPair
6 8 from boards.models.image import PostImage
7 9 from boards.models.attachment import Attachment
8 10 from boards.models.thread import Thread
9 11 from boards.models.post import Post
10 12 from boards.models.tag import Tag
11 13 from boards.models.user import Ban
12 14 from boards.models.banner import Banner
@@ -1,70 +1,79 b''
1 1 import os
2 2 import re
3 3
4 4 from django.core.files.uploadedfile import SimpleUploadedFile, \
5 5 TemporaryUploadedFile
6 6 from pytube import YouTube
7 7 import requests
8 8
9 9 from boards.utils import validate_file_size
10 10
11 11 YOUTUBE_VIDEO_FORMAT = 'webm'
12 12
13 13 HTTP_RESULT_OK = 200
14 14
15 15 HEADER_CONTENT_LENGTH = 'content-length'
16 16 HEADER_CONTENT_TYPE = 'content-type'
17 17
18 18 FILE_DOWNLOAD_CHUNK_BYTES = 200000
19 19
20 20 YOUTUBE_URL = re.compile(r'https?://((www\.)?youtube\.com/watch\?v=|youtu.be/)[-\w]+')
21 21
22 22
23 23 class Downloader:
24 24 @staticmethod
25 25 def handles(url: str) -> bool:
26 26 return False
27 27
28 28 @staticmethod
29 29 def download(url: str):
30 30 # Verify content headers
31 31 response_head = requests.head(url, verify=False)
32 32 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
33 33 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
34 34 if length_header:
35 35 length = int(length_header)
36 36 validate_file_size(length)
37 37 # Get the actual content into memory
38 38 response = requests.get(url, verify=False, stream=True)
39 39
40 40 # Download file, stop if the size exceeds limit
41 41 size = 0
42 42
43 43 # Set a dummy file name that will be replaced
44 44 # anyway, just keep the valid extension
45 45 filename = 'file.' + content_type.split('/')[1]
46 46
47 47 file = TemporaryUploadedFile(filename, content_type, 0, None, None)
48 48 for chunk in response.iter_content(FILE_DOWNLOAD_CHUNK_BYTES):
49 49 size += len(chunk)
50 50 validate_file_size(size)
51 51 file.write(chunk)
52 52
53 53 if response.status_code == HTTP_RESULT_OK:
54 54 return file
55 55
56 56
57 def download(url):
58 for downloader in Downloader.__subclasses__():
59 if downloader.handles(url):
60 return downloader.download(url)
61 # If nobody of the specific downloaders handles this, use generic
62 # one
63 return Downloader.download(url)
64
65
57 66 class YouTubeDownloader(Downloader):
58 67 @staticmethod
59 68 def download(url: str):
60 69 yt = YouTube()
61 70 yt.from_url(url)
62 71 videos = yt.filter(YOUTUBE_VIDEO_FORMAT)
63 72 if len(videos) > 0:
64 73 video = videos[0]
65 74 return Downloader.download(video.url)
66 75
67 76 @staticmethod
68 77 def handles(url: str) -> bool:
69 78 return YOUTUBE_URL.match(url)
70 79
@@ -1,369 +1,381 b''
1 import logging
2 import re
3 1 import uuid
4 2
3 import re
4 from boards import settings
5 from boards.abstracts.tripcode import Tripcode
6 from boards.models import PostImage, Attachment, KeyPair, GlobalId
7 from boards.models.base import Viewable
8 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
9 from boards.models.post.manager import PostManager
5 10 from boards.utils import datetime_to_epoch
6 11 from django.core.exceptions import ObjectDoesNotExist
7 12 from django.core.urlresolvers import reverse
8 13 from django.db import models
9 14 from django.db.models import TextField, QuerySet
10 from django.template.defaultfilters import striptags, truncatewords
15 from django.template.defaultfilters import truncatewords, striptags
11 16 from django.template.loader import render_to_string
12 from django.utils import timezone
13 from django.db.models.signals import post_save, pre_save
14 from django.dispatch import receiver
15
16 from boards import settings
17 from boards.abstracts.tripcode import Tripcode
18 from boards.mdx_neboard import get_parser
19 from boards.models import PostImage, Attachment
20 from boards.models.base import Viewable
21 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
22 from boards.models.post.manager import PostManager
23 from boards.models.user import Notification
24 17
25 18 CSS_CLS_HIDDEN_POST = 'hidden_post'
26 19 CSS_CLS_DEAD_POST = 'dead_post'
27 20 CSS_CLS_ARCHIVE_POST = 'archive_post'
28 21 CSS_CLS_POST = 'post'
29 22 CSS_CLS_MONOCHROME = 'monochrome'
30 23
31 24 TITLE_MAX_WORDS = 10
32 25
33 26 APP_LABEL_BOARDS = 'boards'
34 27
35 28 BAN_REASON_AUTO = 'Auto'
36 29
37 30 IMAGE_THUMB_SIZE = (200, 150)
38 31
39 32 TITLE_MAX_LENGTH = 200
40 33
41 34 REGEX_REPLY = re.compile(r'\[post\](\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*)?')
42 37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
43 38
44 39 PARAMETER_TRUNCATED = 'truncated'
45 40 PARAMETER_TAG = 'tag'
46 41 PARAMETER_OFFSET = 'offset'
47 42 PARAMETER_DIFF_TYPE = 'type'
48 43 PARAMETER_CSS_CLASS = 'css_class'
49 44 PARAMETER_THREAD = 'thread'
50 45 PARAMETER_IS_OPENING = 'is_opening'
51 46 PARAMETER_POST = 'post'
52 47 PARAMETER_OP_ID = 'opening_post_id'
53 48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
54 49 PARAMETER_REPLY_LINK = 'reply_link'
55 50 PARAMETER_NEED_OP_DATA = 'need_op_data'
56 51
57 52 POST_VIEW_PARAMS = (
58 53 'need_op_data',
59 54 'reply_link',
60 55 'need_open_link',
61 56 'truncated',
62 57 'mode_tree',
63 58 'perms',
64 59 'tree_depth',
65 60 )
66 61
67 62
68 63 class Post(models.Model, Viewable):
69 64 """A post is a message."""
70 65
71 66 objects = PostManager()
72 67
73 68 class Meta:
74 69 app_label = APP_LABEL_BOARDS
75 70 ordering = ('id',)
76 71
77 72 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
78 73 pub_time = models.DateTimeField()
79 74 text = TextField(blank=True, null=True)
80 75 _text_rendered = TextField(blank=True, null=True, editable=False)
81 76
82 77 images = models.ManyToManyField(PostImage, null=True, blank=True,
83 78 related_name='post_images', db_index=True)
84 79 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
85 80 related_name='attachment_posts')
86 81
87 82 poster_ip = models.GenericIPAddressField()
88 83
89 84 # TODO This field can be removed cause UID is used for update now
90 85 last_edit_time = models.DateTimeField()
91 86
92 87 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
93 88 null=True,
94 89 blank=True, related_name='refposts',
95 90 db_index=True)
96 91 refmap = models.TextField(null=True, blank=True)
97 92 threads = models.ManyToManyField('Thread', db_index=True,
98 93 related_name='multi_replies')
99 94 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
100 95
101 96 url = models.TextField()
102 97 uid = models.TextField(db_index=True)
103 98
99 # Global ID with author key. If the message was downloaded from another
100 # server, this indicates the server.
101 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
102 on_delete=models.CASCADE)
103
104 104 tripcode = models.CharField(max_length=50, blank=True, default='')
105 105 opening = models.BooleanField(db_index=True)
106 106 hidden = models.BooleanField(default=False)
107 107
108 108 def __str__(self):
109 109 return 'P#{}/{}'.format(self.id, self.get_title())
110 110
111 111 def get_title(self) -> str:
112 112 return self.title
113 113
114 114 def get_title_or_text(self):
115 115 title = self.get_title()
116 116 if not title:
117 117 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
118 118
119 119 return title
120 120
121 121 def build_refmap(self) -> None:
122 122 """
123 123 Builds a replies map string from replies list. This is a cache to stop
124 124 the server from recalculating the map on every post show.
125 125 """
126 126
127 127 post_urls = [refpost.get_link_view()
128 128 for refpost in self.referenced_posts.all()]
129 129
130 130 self.refmap = ', '.join(post_urls)
131 131
132 132 def is_referenced(self) -> bool:
133 133 return self.refmap and len(self.refmap) > 0
134 134
135 135 def is_opening(self) -> bool:
136 136 """
137 137 Checks if this is an opening post or just a reply.
138 138 """
139 139
140 140 return self.opening
141 141
142 142 def get_absolute_url(self, thread=None):
143 143 url = None
144 144
145 145 if thread is None:
146 146 thread = self.get_thread()
147 147
148 148 # Url is cached only for the "main" thread. When getting url
149 149 # for other threads, do it manually.
150 150 if self.url:
151 151 url = self.url
152 152
153 153 if url is None:
154 154 opening = self.is_opening()
155 155 opening_id = self.id if opening else thread.get_opening_post_id()
156 156 url = reverse('thread', kwargs={'post_id': opening_id})
157 157 if not opening:
158 158 url += '#' + str(self.id)
159 159
160 160 return url
161 161
162 162 def get_thread(self):
163 163 return self.thread
164 164
165 165 def get_thread_id(self):
166 166 return self.thread_id
167 167
168 168 def get_threads(self) -> QuerySet:
169 169 """
170 170 Gets post's thread.
171 171 """
172 172
173 173 return self.threads
174 174
175 175 def _get_cache_key(self):
176 176 return [datetime_to_epoch(self.last_edit_time)]
177 177
178 178 def get_view(self, *args, **kwargs) -> str:
179 179 """
180 180 Renders post's HTML view. Some of the post params can be passed over
181 181 kwargs for the means of caching (if we view the thread, some params
182 182 are same for every post and don't need to be computed over and over.
183 183 """
184 184
185 185 thread = self.get_thread()
186 186
187 187 css_classes = [CSS_CLS_POST]
188 188 if thread.is_archived():
189 189 css_classes.append(CSS_CLS_ARCHIVE_POST)
190 190 elif not thread.can_bump():
191 191 css_classes.append(CSS_CLS_DEAD_POST)
192 192 if self.is_hidden():
193 193 css_classes.append(CSS_CLS_HIDDEN_POST)
194 194 if thread.is_monochrome():
195 195 css_classes.append(CSS_CLS_MONOCHROME)
196 196
197 197 params = dict()
198 198 for param in POST_VIEW_PARAMS:
199 199 if param in kwargs:
200 200 params[param] = kwargs[param]
201 201
202 202 params.update({
203 203 PARAMETER_POST: self,
204 204 PARAMETER_IS_OPENING: self.is_opening(),
205 205 PARAMETER_THREAD: thread,
206 206 PARAMETER_CSS_CLASS: ' '.join(css_classes),
207 207 })
208 208
209 209 return render_to_string('boards/post.html', params)
210 210
211 211 def get_search_view(self, *args, **kwargs):
212 212 return self.get_view(need_op_data=True, *args, **kwargs)
213 213
214 214 def get_first_image(self) -> PostImage:
215 215 return self.images.earliest('id')
216 216
217 def delete(self, using=None):
217 def set_global_id(self, key_pair=None):
218 218 """
219 Deletes all post images and the post itself.
219 Sets global id based on the given key pair. If no key pair is given,
220 default one is used.
220 221 """
221 222
222 for image in self.images.all():
223 image_refs_count = image.post_images.count()
224 if image_refs_count == 1:
225 image.delete()
223 if key_pair:
224 key = key_pair
225 else:
226 try:
227 key = KeyPair.objects.get(primary=True)
228 except KeyPair.DoesNotExist:
229 # Do not update the global id because there is no key defined
230 return
231 global_id = GlobalId(key_type=key.key_type,
232 key=key.public_key,
233 local_id=self.id)
234 global_id.save()
235
236 self.global_id = global_id
237
238 self.save(update_fields=['global_id'])
239
240 def get_pub_time_str(self):
241 return str(self.pub_time)
226 242
227 for attachment in self.attachments.all():
228 attachment_refs_count = attachment.attachment_posts.count()
229 if attachment_refs_count == 1:
230 attachment.delete()
243 def get_replied_ids(self):
244 """
245 Gets ID list of the posts that this post replies.
246 """
247
248 raw_text = self.get_raw_text()
231 249
232 thread = self.get_thread()
233 thread.last_edit_time = timezone.now()
234 thread.save()
250 local_replied = REGEX_REPLY.findall(raw_text)
251 global_replied = []
252 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
253 key_type = match[0]
254 key = match[1]
255 local_id = match[2]
235 256
236 super(Post, self).delete(using)
237
238 logging.getLogger('boards.post.delete').info(
239 'Deleted post {}'.format(self))
257 try:
258 global_id = GlobalId.objects.get(key_type=key_type,
259 key=key, local_id=local_id)
260 for post in Post.objects.filter(global_id=global_id).only('id'):
261 global_replied.append(post.id)
262 except GlobalId.DoesNotExist:
263 pass
264 return local_replied + global_replied
240 265
241 266 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
242 267 include_last_update=False) -> str:
243 268 """
244 269 Gets post HTML or JSON data that can be rendered on a page or used by
245 270 API.
246 271 """
247 272
248 273 return get_exporter(format_type).export(self, request,
249 274 include_last_update)
250 275
251 276 def notify_clients(self, recursive=True):
252 277 """
253 278 Sends post HTML data to the thread web socket.
254 279 """
255 280
256 281 if not settings.get_bool('External', 'WebsocketsEnabled'):
257 282 return
258 283
259 284 thread_ids = list()
260 285 for thread in self.get_threads().all():
261 286 thread_ids.append(thread.id)
262 287
263 288 thread.notify_clients()
264 289
265 290 if recursive:
266 291 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
267 292 post_id = reply_number.group(1)
268 293
269 294 try:
270 295 ref_post = Post.objects.get(id=post_id)
271 296
272 297 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
273 298 # If post is in this thread, its thread was already notified.
274 299 # Otherwise, notify its thread separately.
275 300 ref_post.notify_clients(recursive=False)
276 301 except ObjectDoesNotExist:
277 302 pass
278 303
279 304 def build_url(self):
280 305 self.url = self.get_absolute_url()
281 306 self.save(update_fields=['url'])
282 307
283 308 def save(self, force_insert=False, force_update=False, using=None,
284 309 update_fields=None):
285 310 new_post = self.id is None
286 311
287 312 self.uid = str(uuid.uuid4())
288 313 if update_fields is not None and 'uid' not in update_fields:
289 314 update_fields += ['uid']
290 315
291 316 if not new_post:
292 317 for thread in self.get_threads().all():
293 318 thread.last_edit_time = self.last_edit_time
294 319
295 320 thread.save(update_fields=['last_edit_time', 'status'])
296 321
297 322 super().save(force_insert, force_update, using, update_fields)
298 323
299 324 if self.url is None:
300 325 self.build_url()
301 326
302 327 def get_text(self) -> str:
303 328 return self._text_rendered
304 329
305 330 def get_raw_text(self) -> str:
306 331 return self.text
307 332
333 def get_sync_text(self) -> str:
334 """
335 Returns text applicable for sync. It has absolute post reflinks.
336 """
337
338 replacements = dict()
339 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
340 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
341 replacements[post_id] = absolute_post_id
342
343 text = self.get_raw_text() or ''
344 for key in replacements:
345 text = text.replace('[post]{}[/post]'.format(key),
346 '[post]{}[/post]'.format(replacements[key]))
347 text = text.replace('\r\n', '\n').replace('\r', '\n')
348
349 return text
350
308 351 def connect_threads(self, opening_posts):
309 352 for opening_post in opening_posts:
310 353 threads = opening_post.get_threads().all()
311 354 for thread in threads:
312 355 if thread.can_bump():
313 356 thread.update_bump_status()
314 357
315 358 thread.last_edit_time = self.last_edit_time
316 359 thread.save(update_fields=['last_edit_time', 'status'])
317 360 self.threads.add(opening_post.get_thread())
318 361
319 362 def get_tripcode(self):
320 363 if self.tripcode:
321 364 return Tripcode(self.tripcode)
322 365
323 366 def get_link_view(self):
324 367 """
325 368 Gets view of a reflink to the post.
326 369 """
327 370 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
328 371 self.id)
329 372 if self.is_opening():
330 373 result = '<b>{}</b>'.format(result)
331 374
332 375 return result
333 376
334 377 def is_hidden(self) -> bool:
335 378 return self.hidden
336 379
337 380 def set_hidden(self, hidden):
338 381 self.hidden = hidden
339
340
341 # SIGNALS (Maybe move to other module?)
342 @receiver(post_save, sender=Post)
343 def connect_replies(instance, **kwargs):
344 for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()):
345 post_id = reply_number.group(1)
346
347 try:
348 referenced_post = Post.objects.get(id=post_id)
349
350 # Connect only to posts that are not connected to already
351 if not referenced_post.referenced_posts.filter(id=instance.id).exists():
352 referenced_post.referenced_posts.add(instance)
353 referenced_post.last_edit_time = instance.pub_time
354 referenced_post.build_refmap()
355 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
356 except ObjectDoesNotExist:
357 pass
358
359
360 @receiver(post_save, sender=Post)
361 def connect_notifications(instance, **kwargs):
362 for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()):
363 user_name = reply_number.group(1).lower()
364 Notification.objects.get_or_create(name=user_name, post=instance)
365
366
367 @receiver(pre_save, sender=Post)
368 def preparse_text(instance, **kwargs):
369 instance._text_rendered = get_parser().parse(instance.get_raw_text())
@@ -1,133 +1,160 b''
1 1 import logging
2 2
3 3 from datetime import datetime, timedelta, date
4 4 from datetime import time as dtime
5 5
6 6 from django.db import models, transaction
7 7 from django.utils import timezone
8 8
9 9 import boards
10 10
11 11 from boards.models.user import Ban
12 12 from boards.mdx_neboard import Parser
13 13 from boards.models import PostImage, Attachment
14 14 from boards import utils
15 15
16 16 __author__ = 'neko259'
17 17
18 18 IMAGE_TYPES = (
19 19 'jpeg',
20 20 'jpg',
21 21 'png',
22 22 'bmp',
23 23 'gif',
24 24 )
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27 NO_IP = '0.0.0.0'
28 28
29 29
30 30 class PostManager(models.Manager):
31 31 @transaction.atomic
32 32 def create_post(self, title: str, text: str, file=None, thread=None,
33 33 ip=NO_IP, tags: list=None, opening_posts: list=None,
34 34 tripcode='', monochrome=False, images=[]):
35 35 """
36 36 Creates new post
37 37 """
38 38
39 39 if thread is not None and thread.is_archived():
40 40 raise Exception('Cannot post into an archived thread')
41 41
42 42 if not utils.is_anonymous_mode():
43 43 is_banned = Ban.objects.filter(ip=ip).exists()
44 44 else:
45 45 is_banned = False
46 46
47 47 # TODO Raise specific exception and catch it in the views
48 48 if is_banned:
49 49 raise Exception("This user is banned")
50 50
51 51 if not tags:
52 52 tags = []
53 53 if not opening_posts:
54 54 opening_posts = []
55 55
56 56 posting_time = timezone.now()
57 57 new_thread = False
58 58 if not thread:
59 59 thread = boards.models.thread.Thread.objects.create(
60 60 bump_time=posting_time, last_edit_time=posting_time,
61 61 monochrome=monochrome)
62 62 list(map(thread.tags.add, tags))
63 63 boards.models.thread.Thread.objects.process_oldest_threads()
64 64 new_thread = True
65 65
66 66 pre_text = Parser().preparse(text)
67 67
68 68 post = self.create(title=title,
69 69 text=pre_text,
70 70 pub_time=posting_time,
71 71 poster_ip=ip,
72 72 thread=thread,
73 73 last_edit_time=posting_time,
74 74 tripcode=tripcode,
75 75 opening=new_thread)
76 76 post.threads.add(thread)
77 77
78 78 logger = logging.getLogger('boards.post.create')
79 79
80 80 logger.info('Created post [{}] with text [{}] by {}'.format(post,
81 81 post.get_text(),post.poster_ip))
82 82
83 # TODO Move this to other place
84 83 if file:
85 file_type = file.name.split('.')[-1].lower()
86 if file_type in IMAGE_TYPES:
87 post.images.add(PostImage.objects.create_with_hash(file))
88 else:
89 post.attachments.add(Attachment.objects.create_with_hash(file))
84 self._add_file_to_post(file, post)
90 85 for image in images:
91 86 post.images.add(image)
92 87
93 88 post.connect_threads(opening_posts)
89 post.set_global_id()
94 90
95 91 # Thread needs to be bumped only when the post is already created
96 92 if not new_thread:
97 93 thread.last_edit_time = posting_time
98 94 thread.bump()
99 95 thread.save()
100 96
101 97 return post
102 98
103 99 def delete_posts_by_ip(self, ip):
104 100 """
105 101 Deletes all posts of the author with same IP
106 102 """
107 103
108 104 posts = self.filter(poster_ip=ip)
109 105 for post in posts:
110 106 post.delete()
111 107
112 108 @utils.cached_result()
113 109 def get_posts_per_day(self) -> float:
114 110 """
115 111 Gets average count of posts per day for the last 7 days
116 112 """
117 113
118 114 day_end = date.today()
119 115 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
120 116
121 117 day_time_start = timezone.make_aware(datetime.combine(
122 118 day_start, dtime()), timezone.get_current_timezone())
123 119 day_time_end = timezone.make_aware(datetime.combine(
124 120 day_end, dtime()), timezone.get_current_timezone())
125 121
126 122 posts_per_period = float(self.filter(
127 123 pub_time__lte=day_time_end,
128 124 pub_time__gte=day_time_start).count())
129 125
130 126 ppd = posts_per_period / POSTS_PER_DAY_RANGE
131 127
132 128 return ppd
133 129
130 @transaction.atomic
131 def import_post(self, title: str, text: str, pub_time: str, global_id,
132 opening_post=None, tags=list(), files=list()):
133 is_opening = opening_post is None
134 if is_opening:
135 thread = boards.models.thread.Thread.objects.create(
136 bump_time=pub_time, last_edit_time=pub_time)
137 list(map(thread.tags.add, tags))
138 else:
139 thread = opening_post.get_thread()
140
141 post = self.create(title=title, text=text,
142 pub_time=pub_time,
143 poster_ip=NO_IP,
144 last_edit_time=pub_time,
145 global_id=global_id,
146 opening=is_opening,
147 thread=thread)
148
149 # TODO Add files
150 for file in files:
151 self._add_file_to_post(file, post)
152
153 post.threads.add(thread)
154
155 def _add_file_to_post(self, file, post):
156 file_type = file.name.split('.')[-1].lower()
157 if file_type in IMAGE_TYPES:
158 post.images.add(PostImage.objects.create_with_hash(file))
159 else:
160 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,46 +1,45 b''
1 1 from django.db import models
2
3 import boards.models.post
2 import boards
4 3
5 4 __author__ = 'neko259'
6 5
7 6 BAN_REASON_AUTO = 'Auto'
8 7 BAN_REASON_MAX_LENGTH = 200
9 8
10 9
11 10 class Ban(models.Model):
12 11
13 12 class Meta:
14 13 app_label = 'boards'
15 14
16 15 ip = models.GenericIPAddressField()
17 16 reason = models.CharField(default=BAN_REASON_AUTO,
18 17 max_length=BAN_REASON_MAX_LENGTH)
19 18 can_read = models.BooleanField(default=True)
20 19
21 20 def __str__(self):
22 21 return self.ip
23 22
24 23
25 24 class NotificationManager(models.Manager):
26 25 def get_notification_posts(self, usernames: list, last: int = None):
27 26 lower_names = [username.lower() for username in usernames]
28 27 posts = boards.models.post.Post.objects.filter(
29 28 notification__name__in=lower_names).distinct()
30 29 if last is not None:
31 30 posts = posts.filter(id__gt=last)
32 31 posts = posts.order_by('-id')
33 32
34 33 return posts
35 34
36 35
37 36 class Notification(models.Model):
38 37
39 38 class Meta:
40 39 app_label = 'boards'
41 40
42 41 objects = NotificationManager()
43 42
44 43 post = models.ForeignKey('Post')
45 44 name = models.TextField()
46 45
@@ -1,578 +1,583 b''
1 1 * {
2 2 text-decoration: none;
3 3 font-weight: inherit;
4 4 }
5 5
6 6 b, strong {
7 7 font-weight: bold;
8 8 }
9 9
10 10 html {
11 11 background: #555;
12 12 color: #ffffff;
13 13 }
14 14
15 15 body {
16 16 margin: 0;
17 17 }
18 18
19 19 #admin_panel {
20 20 background: #FF0000;
21 21 color: #00FF00
22 22 }
23 23
24 24 .input_field_error {
25 25 color: #FF0000;
26 26 }
27 27
28 28 .title {
29 29 font-weight: bold;
30 30 color: #ffcc00;
31 31 }
32 32
33 33 .link, a {
34 34 color: #afdcec;
35 35 }
36 36
37 37 .block {
38 38 display: inline-block;
39 39 vertical-align: top;
40 40 }
41 41
42 42 .tag {
43 43 color: #FFD37D;
44 44 }
45 45
46 46 .post_id {
47 47 color: #fff380;
48 48 }
49 49
50 50 .post, .dead_post, .archive_post, #posts-table {
51 51 background: #333;
52 52 padding: 10px;
53 53 clear: left;
54 54 word-wrap: break-word;
55 55 border-top: 1px solid #777;
56 56 border-bottom: 1px solid #777;
57 57 }
58 58
59 59 .post + .post {
60 60 border-top: none;
61 61 }
62 62
63 63 .dead_post + .dead_post {
64 64 border-top: none;
65 65 }
66 66
67 67 .archive_post + .archive_post {
68 68 border-top: none;
69 69 }
70 70
71 71 .metadata {
72 72 padding-top: 5px;
73 73 margin-top: 10px;
74 74 border-top: solid 1px #666;
75 75 color: #ddd;
76 76 }
77 77
78 78 .navigation_panel, .tag_info {
79 79 background: #222;
80 80 margin-bottom: 5px;
81 81 margin-top: 5px;
82 82 padding: 10px;
83 83 border-bottom: solid 1px #888;
84 84 border-top: solid 1px #888;
85 85 color: #eee;
86 86 }
87 87
88 88 .navigation_panel .link:first-child {
89 89 border-right: 1px solid #fff;
90 90 font-weight: bold;
91 91 margin-right: 1ex;
92 92 padding-right: 1ex;
93 93 }
94 94
95 95 .navigation_panel .right-link {
96 96 border-left: 1px solid #fff;
97 97 border-right: none;
98 98 float: right;
99 99 margin-left: 1ex;
100 100 margin-right: 0;
101 101 padding-left: 1ex;
102 102 padding-right: 0;
103 103 }
104 104
105 105 .navigation_panel .link {
106 106 font-weight: bold;
107 107 }
108 108
109 109 .navigation_panel::after, .post::after {
110 110 clear: both;
111 111 content: ".";
112 112 display: block;
113 113 height: 0;
114 114 line-height: 0;
115 115 visibility: hidden;
116 116 }
117 117
118 118 .tag_info {
119 119 text-align: center;
120 120 }
121 121
122 122 .tag_info > .tag-text-data {
123 123 text-align: left;
124 124 max-width: 30em;
125 125 }
126 126
127 127 .header {
128 128 border-bottom: solid 2px #ccc;
129 129 margin-bottom: 5px;
130 130 border-top: none;
131 131 margin-top: 0;
132 132 }
133 133
134 134 .footer {
135 135 border-top: solid 2px #ccc;
136 136 margin-top: 5px;
137 137 border-bottom: none;
138 138 margin-bottom: 0;
139 139 }
140 140
141 141 p, .br {
142 142 margin-top: .5em;
143 143 margin-bottom: .5em;
144 144 }
145 145
146 146 .post-form-w {
147 147 background: #333344;
148 148 border-top: solid 1px #888;
149 149 border-bottom: solid 1px #888;
150 150 color: #fff;
151 151 padding: 10px;
152 152 margin-bottom: 5px;
153 153 margin-top: 5px;
154 154 }
155 155
156 156 .form-row {
157 157 width: 100%;
158 158 display: table-row;
159 159 }
160 160
161 161 .form-label {
162 162 padding: .25em 1ex .25em 0;
163 163 vertical-align: top;
164 164 display: table-cell;
165 165 }
166 166
167 167 .form-input {
168 168 padding: .25em 0;
169 169 width: 100%;
170 170 display: table-cell;
171 171 }
172 172
173 173 .form-errors {
174 174 font-weight: bolder;
175 175 vertical-align: middle;
176 176 display: table-cell;
177 177 }
178 178
179 179 .post-form input:not([name="image"]):not([type="checkbox"]):not([type="submit"]), .post-form textarea, .post-form select {
180 180 background: #333;
181 181 color: #fff;
182 182 border: solid 1px;
183 183 padding: 0;
184 184 font: medium sans-serif;
185 185 width: 100%;
186 186 }
187 187
188 188 .post-form textarea {
189 189 resize: vertical;
190 190 }
191 191
192 192 .form-submit {
193 193 display: table;
194 194 margin-bottom: 1ex;
195 195 margin-top: 1ex;
196 196 }
197 197
198 198 .form-title {
199 199 font-weight: bold;
200 200 font-size: 2ex;
201 201 margin-bottom: 0.5ex;
202 202 }
203 203
204 204 input[type="submit"], button {
205 205 background: #222;
206 206 border: solid 2px #fff;
207 207 color: #fff;
208 208 padding: 0.5ex;
209 209 margin-right: 0.5ex;
210 210 }
211 211
212 212 input[type="submit"]:hover {
213 213 background: #060;
214 214 }
215 215
216 216 .form-submit > button:hover {
217 217 background: #006;
218 218 }
219 219
220 220 blockquote {
221 221 border-left: solid 2px;
222 222 padding-left: 5px;
223 223 color: #B1FB17;
224 224 margin: 0;
225 225 }
226 226
227 227 .post > .image {
228 228 float: left;
229 229 margin: 0 1ex .5ex 0;
230 230 min-width: 1px;
231 231 text-align: center;
232 232 display: table-row;
233 233 }
234 234
235 235 .post > .metadata {
236 236 clear: left;
237 237 }
238 238
239 239 .get {
240 240 font-weight: bold;
241 241 color: #d55;
242 242 }
243 243
244 244 * {
245 245 text-decoration: none;
246 246 }
247 247
248 248 .dead_post > .post-info {
249 249 font-style: italic;
250 250 }
251 251
252 252 .archive_post > .post-info {
253 253 text-decoration: line-through;
254 254 }
255 255
256 256 .mark_btn {
257 257 border: 1px solid;
258 258 padding: 2px 2ex;
259 259 display: inline-block;
260 260 margin: 0 5px 4px 0;
261 261 }
262 262
263 263 .mark_btn:hover {
264 264 background: #555;
265 265 }
266 266
267 267 .quote {
268 268 color: #92cf38;
269 269 font-style: italic;
270 270 }
271 271
272 272 .multiquote {
273 273 padding: 3px;
274 274 display: inline-block;
275 275 background: #222;
276 276 border-style: solid;
277 277 border-width: 1px 1px 1px 4px;
278 278 font-size: 0.9em;
279 279 }
280 280
281 281 .spoiler {
282 282 background: black;
283 283 color: black;
284 284 }
285 285
286 286 .spoiler:hover {
287 287 color: #ddd;
288 288 }
289 289
290 290 .comment {
291 291 color: #eb2;
292 292 }
293 293
294 294 a:hover {
295 295 text-decoration: underline;
296 296 }
297 297
298 298 .last-replies {
299 299 margin-left: 3ex;
300 300 margin-right: 3ex;
301 301 border-left: solid 1px #777;
302 302 border-right: solid 1px #777;
303 303 }
304 304
305 305 .last-replies > .post:first-child {
306 306 border-top: none;
307 307 }
308 308
309 309 .thread {
310 310 margin-bottom: 3ex;
311 311 margin-top: 1ex;
312 312 }
313 313
314 314 .post:target {
315 315 border: solid 2px white;
316 316 }
317 317
318 318 pre{
319 319 white-space:pre-wrap
320 320 }
321 321
322 322 li {
323 323 list-style-position: inside;
324 324 }
325 325
326 326 .fancybox-skin {
327 327 position: relative;
328 328 background-color: #fff;
329 329 color: #ddd;
330 330 text-shadow: none;
331 331 }
332 332
333 333 .fancybox-image {
334 334 border: 1px solid black;
335 335 }
336 336
337 337 .image-mode-tab {
338 338 background: #444;
339 339 color: #eee;
340 340 margin-top: 5px;
341 341 padding: 5px;
342 342 border-top: 1px solid #888;
343 343 border-bottom: 1px solid #888;
344 344 }
345 345
346 346 .image-mode-tab > label {
347 347 margin: 0 1ex;
348 348 }
349 349
350 350 .image-mode-tab > label > input {
351 351 margin-right: .5ex;
352 352 }
353 353
354 354 #posts-table {
355 355 margin-top: 5px;
356 356 margin-bottom: 5px;
357 357 }
358 358
359 359 .tag_info > h2 {
360 360 margin: 0;
361 361 }
362 362
363 363 .post-info {
364 364 color: #ddd;
365 365 margin-bottom: 1ex;
366 366 }
367 367
368 368 .moderator_info {
369 369 color: #e99d41;
370 370 opacity: 0.4;
371 371 }
372 372
373 373 .moderator_info:hover {
374 374 opacity: 1;
375 375 }
376 376
377 377 .refmap {
378 378 font-size: 0.9em;
379 379 color: #ccc;
380 380 margin-top: 1em;
381 381 }
382 382
383 383 .fav {
384 384 color: yellow;
385 385 }
386 386
387 387 .not_fav {
388 388 color: #ccc;
389 389 }
390 390
391 391 .form-email {
392 392 display: none;
393 393 }
394 394
395 395 .bar-value {
396 396 background: rgba(50, 55, 164, 0.45);
397 397 font-size: 0.9em;
398 398 height: 1.5em;
399 399 }
400 400
401 401 .bar-bg {
402 402 position: relative;
403 403 border-top: solid 1px #888;
404 404 border-bottom: solid 1px #888;
405 405 margin-top: 5px;
406 406 overflow: hidden;
407 407 }
408 408
409 409 .bar-text {
410 410 padding: 2px;
411 411 position: absolute;
412 412 left: 0;
413 413 top: 0;
414 414 }
415 415
416 416 .page_link {
417 417 background: #444;
418 418 border-top: solid 1px #888;
419 419 border-bottom: solid 1px #888;
420 420 padding: 5px;
421 421 color: #eee;
422 422 font-size: 2ex;
423 423 margin-top: .5ex;
424 424 margin-bottom: .5ex;
425 425 }
426 426
427 427 .skipped_replies {
428 428 padding: 5px;
429 429 margin-left: 3ex;
430 430 margin-right: 3ex;
431 431 border-left: solid 1px #888;
432 432 border-right: solid 1px #888;
433 433 border-bottom: solid 1px #888;
434 434 background: #000;
435 435 }
436 436
437 437 .current_page {
438 438 padding: 2px;
439 439 background-color: #afdcec;
440 440 color: #000;
441 441 }
442 442
443 443 .current_mode {
444 444 font-weight: bold;
445 445 }
446 446
447 447 .gallery_image {
448 448 border: solid 1px;
449 449 margin: 0.5ex;
450 450 text-align: center;
451 451 padding: 1ex;
452 452 }
453 453
454 454 code {
455 455 border: dashed 1px #ccc;
456 456 background: #111;
457 457 padding: 2px;
458 458 font-size: 1.2em;
459 459 display: inline-block;
460 460 }
461 461
462 462 pre {
463 463 overflow: auto;
464 464 }
465 465
466 466 .img-full {
467 467 background: #222;
468 468 border: solid 1px white;
469 469 }
470 470
471 471 .tag_item {
472 472 display: inline-block;
473 473 }
474 474
475 475 #id_models li {
476 476 list-style: none;
477 477 }
478 478
479 479 #id_q {
480 480 margin-left: 1ex;
481 481 }
482 482
483 483 ul {
484 484 padding-left: 0px;
485 485 }
486 486
487 487 .quote-header {
488 488 border-bottom: 2px solid #ddd;
489 489 margin-bottom: 1ex;
490 490 padding-bottom: .5ex;
491 491 color: #ddd;
492 492 font-size: 1.2em;
493 493 }
494 494
495 .global-id {
496 font-weight: bolder;
497 opacity: .5;
498 }
499
495 500 /* Reflink preview */
496 501 .post_preview {
497 502 border-left: 1px solid #777;
498 503 border-right: 1px solid #777;
499 504 max-width: 600px;
500 505 }
501 506
502 507 /* Code highlighter */
503 508 .hljs {
504 509 color: #fff;
505 510 background: #000;
506 511 display: inline-block;
507 512 }
508 513
509 514 .hljs, .hljs-subst, .hljs-tag .hljs-title, .lisp .hljs-title, .clojure .hljs-built_in, .nginx .hljs-title {
510 515 color: #fff;
511 516 }
512 517
513 518 #up {
514 519 position: fixed;
515 520 bottom: 5px;
516 521 right: 5px;
517 522 border: 1px solid #777;
518 523 background: #000;
519 524 padding: 4px;
520 525 opacity: 0.3;
521 526 }
522 527
523 528 #up:hover {
524 529 opacity: 1;
525 530 }
526 531
527 532 .user-cast {
528 533 border: solid #ffffff 1px;
529 534 padding: .2ex;
530 535 background: #152154;
531 536 color: #fff;
532 537 }
533 538
534 539 .highlight {
535 540 background: #222;
536 541 }
537 542
538 543 .post-button-form > button:hover {
539 544 text-decoration: underline;
540 545 }
541 546
542 547 .tree_reply > .post {
543 548 margin-top: 1ex;
544 549 border-left: solid 1px #777;
545 550 padding-right: 0;
546 551 }
547 552
548 553 #preview-text {
549 554 border: solid 1px white;
550 555 margin: 1ex 0 1ex 0;
551 556 padding: 1ex;
552 557 }
553 558
554 559 .image-metadata {
555 560 font-size: 0.9em;
556 561 }
557 562
558 563 .tripcode {
559 564 color: white;
560 565 }
561 566
562 567 #fav-panel {
563 568 border: 1px solid white;
564 569 }
565 570
566 571 .post-blink {
567 572 background-color: #000;
568 573 }
569 574
570 575 #quote-button {
571 576 background-color: black;
572 577 border: solid white 1px;
573 578 padding: 2px;
574 579 }
575 580
576 581 #quote-button:hover {
577 582 background-color: #2d3955;
578 583 } No newline at end of file
@@ -1,105 +1,107 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}" {% if tree_depth %}style="margin-left: {{ tree_depth }}em;"{% endif %}>
7 7 <div class="post-info">
8 8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.id }}</a>
9 9 <span class="title">{{ post.title }}</span>
10 10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 11 {% if post.tripcode %}
12 12 /
13 13 {% with tripcode=post.get_tripcode %}
14 14 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
15 15 class="tripcode" title="{{ tripcode.get_full_text }}"
16 16 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
17 17 {% endwith %}
18 18 {% endif %}
19 19 {% comment %}
20 20 Thread death time needs to be shown only if the thread is alredy archived
21 21 and this is an opening post (thread death time) or a post for popup
22 22 (we don't see OP here so we show the death time in the post itself).
23 23 {% endcomment %}
24 24 {% if thread.is_archived %}
25 25 {% if is_opening %}
26 26 <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
27 27 {% endif %}
28 28 {% endif %}
29 29 {% if is_opening %}
30 30 {% if need_open_link %}
31 31 {% if thread.is_archived %}
32 32 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
33 33 {% else %}
34 34 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
35 35 {% endif %}
36 36 {% endif %}
37 37 {% else %}
38 38 {% if need_op_data %}
39 39 {% with thread.get_opening_post as op %}
40 40 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
41 41 {% endwith %}
42 42 {% endif %}
43 43 {% endif %}
44 44 {% if reply_link and not thread.is_archived %}
45 45 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
46 46 {% endif %}
47 47
48 48 {% if perms.boards.change_post or perms.boards.delete_post or perms.boards.change_thread or perms_boards.delete_thread %}
49 49 <span class="moderator_info">
50 50 {% if perms.boards.change_post or perms.boards.delete_post %}
51 51 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
52 52 {% endif %}
53 53 {% if perms.boards.change_thread or perms_boards.delete_thread %}
54 54 {% if is_opening %}
55 55 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
56 56 {% endif %}
57 57 {% endif %}
58 </form>
58 {% if post.global_id_id %}
59 | <a href="{% url 'post_sync_data' post.id %}">RAW</a>
60 {% endif %}
59 61 </span>
60 62 {% endif %}
61 63 </div>
62 64 {% comment %}
63 65 Post images. Currently only 1 image can be posted and shown, but post model
64 66 supports multiple.
65 67 {% endcomment %}
66 68 {% for image in post.images.all %}
67 69 {{ image.get_view|safe }}
68 70 {% endfor %}
69 71 {% for file in post.attachments.all %}
70 72 {{ file.get_view|safe }}
71 73 {% endfor %}
72 74 {% comment %}
73 75 Post message (text)
74 76 {% endcomment %}
75 77 <div class="message">
76 78 {% autoescape off %}
77 79 {% if truncated %}
78 80 {{ post.get_text|truncatewords_html:50 }}
79 81 {% else %}
80 82 {{ post.get_text }}
81 83 {% endif %}
82 84 {% endautoescape %}
83 85 </div>
84 86 {% if post.is_referenced %}
85 87 {% if not mode_tree %}
86 88 <div class="refmap">
87 89 {% trans "Replies" %}: {{ post.refmap|safe }}
88 90 </div>
89 91 {% endif %}
90 92 {% endif %}
91 93 {% comment %}
92 94 Thread metadata: counters, tags etc
93 95 {% endcomment %}
94 96 {% if is_opening %}
95 97 <div class="metadata">
96 98 {% if is_opening and need_open_link %}
97 99 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
98 100 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
99 101 {% endif %}
100 102 <span class="tags">
101 103 {{ thread.get_tag_url_list|safe }}
102 104 </span>
103 105 </div>
104 106 {% endif %}
105 107 </div>
@@ -1,56 +1,56 b''
1 1 from django.test import TestCase, Client
2 2 import time
3 3 from boards import settings
4 4 from boards.models import Post, Tag
5 5 import neboard
6 6
7 7
8 8 TEST_TAG = 'test_tag'
9 9
10 10 PAGE_404 = 'boards/404.html'
11 11
12 12 TEST_TEXT = 'test text'
13 13
14 14 NEW_THREAD_PAGE = '/'
15 15 THREAD_PAGE_ONE = '/thread/1/'
16 16 HTTP_CODE_REDIRECT = 302
17 17
18 18
19 19 class FormTest(TestCase):
20 20 def test_post_validation(self):
21 21 client = Client()
22 22
23 23 valid_tags = 'tag1 tag_2 тег_3'
24 24 invalid_tags = '$%_356 ---'
25 25 Tag.objects.create(name='tag1', required=True)
26 26
27 27 response = client.post(NEW_THREAD_PAGE, {'title': 'test title',
28 28 'text': TEST_TEXT,
29 29 'tags': valid_tags})
30 30 self.assertEqual(response.status_code, HTTP_CODE_REDIRECT,
31 31 msg='Posting new message failed: got code ' +
32 32 str(response.status_code))
33 33
34 34 self.assertEqual(1, Post.objects.count(),
35 35 msg='No posts were created')
36 36
37 37 client.post(NEW_THREAD_PAGE, {'text': TEST_TEXT,
38 38 'tags': invalid_tags})
39 39 self.assertEqual(1, Post.objects.count(), msg='The validation passed '
40 40 'where it should fail')
41 41
42 42 # Change posting delay so we don't have to wait for 30 seconds or more
43 43 old_posting_delay = neboard.settings.POSTING_DELAY
44 44 # Wait fot the posting delay or we won't be able to post
45 settings.POSTING_DELAY = 1
45 neboard.settings.POSTING_DELAY = 1
46 46 time.sleep(neboard.settings.POSTING_DELAY + 1)
47 47 response = client.post(THREAD_PAGE_ONE, {'text': TEST_TEXT,
48 48 'tags': valid_tags})
49 49 self.assertEqual(HTTP_CODE_REDIRECT, response.status_code,
50 50 msg='Posting new message failed: got code ' +
51 51 str(response.status_code))
52 52 # Restore posting delay
53 53 settings.POSTING_DELAY = old_posting_delay
54 54
55 55 self.assertEqual(2, Post.objects.count(),
56 56 msg='No posts were created')
@@ -1,179 +1,200 b''
1 1 from django.core.paginator import Paginator
2 2 from django.test import TestCase
3 3
4 4 from boards import settings
5 from boards.models import Tag, Post, Thread
5 from boards.models import Tag, Post, Thread, KeyPair
6 6 from boards.models.thread import STATUS_ARCHIVE
7 7
8 8
9 9 class PostTests(TestCase):
10 10
11 11 def _create_post(self):
12 12 tag, created = Tag.objects.get_or_create(name='test_tag')
13 13 return Post.objects.create_post(title='title', text='text',
14 14 tags=[tag])
15 15
16 16 def test_post_add(self):
17 17 """Test adding post"""
18 18
19 19 post = self._create_post()
20 20
21 21 self.assertIsNotNone(post, 'No post was created.')
22 22 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
23 23 'No tags were added to the post.')
24 24
25 25 def test_delete_post(self):
26 26 """Test post deletion"""
27 27
28 28 post = self._create_post()
29 29 post_id = post.id
30 30
31 31 post.delete()
32 32
33 33 self.assertFalse(Post.objects.filter(id=post_id).exists())
34 34
35 35 def test_delete_thread(self):
36 36 """Test thread deletion"""
37 37
38 38 opening_post = self._create_post()
39 39 thread = opening_post.get_thread()
40 40 reply = Post.objects.create_post("", "", thread=thread)
41 41
42 42 thread.delete()
43 43
44 44 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
45 45 'Reply was not deleted with the thread.')
46 46 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
47 47 'Opening post was not deleted with the thread.')
48 48
49 49 def test_post_to_thread(self):
50 50 """Test adding post to a thread"""
51 51
52 52 op = self._create_post()
53 53 post = Post.objects.create_post("", "", thread=op.get_thread())
54 54
55 55 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
56 56 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
57 57 'Post\'s create time doesn\'t match thread last edit'
58 58 ' time')
59 59
60 60 def test_delete_posts_by_ip(self):
61 61 """Test deleting posts with the given ip"""
62 62
63 63 post = self._create_post()
64 64 post_id = post.id
65 65
66 66 Post.objects.delete_posts_by_ip('0.0.0.0')
67 67
68 68 self.assertFalse(Post.objects.filter(id=post_id).exists())
69 69
70 70 def test_get_thread(self):
71 71 """Test getting all posts of a thread"""
72 72
73 73 opening_post = self._create_post()
74 74
75 75 for i in range(2):
76 76 Post.objects.create_post('title', 'text',
77 77 thread=opening_post.get_thread())
78 78
79 79 thread = opening_post.get_thread()
80 80
81 81 self.assertEqual(3, thread.get_replies().count())
82 82
83 83 def test_create_post_with_tag(self):
84 84 """Test adding tag to post"""
85 85
86 86 tag = Tag.objects.create(name='test_tag')
87 87 post = Post.objects.create_post(title='title', text='text', tags=[tag])
88 88
89 89 thread = post.get_thread()
90 90 self.assertIsNotNone(post, 'Post not created')
91 91 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
92 92
93 93 def test_thread_max_count(self):
94 94 """Test deletion of old posts when the max thread count is reached"""
95 95
96 96 for i in range(settings.get_int('Messages', 'MaxThreadCount') + 1):
97 97 self._create_post()
98 98
99 99 self.assertEqual(settings.get_int('Messages', 'MaxThreadCount'),
100 100 len(Thread.objects.exclude(status=STATUS_ARCHIVE)))
101 101
102 102 def test_pages(self):
103 103 """Test that the thread list is properly split into pages"""
104 104
105 105 for i in range(settings.get_int('Messages', 'MaxThreadCount')):
106 106 self._create_post()
107 107
108 108 all_threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
109 109
110 110 paginator = Paginator(Thread.objects.exclude(status=STATUS_ARCHIVE),
111 111 settings.get_int('View', 'ThreadsPerPage'))
112 112 posts_in_second_page = paginator.page(2).object_list
113 113 first_post = posts_in_second_page[0]
114 114
115 115 self.assertEqual(all_threads[settings.get_int('View', 'ThreadsPerPage')].id,
116 116 first_post.id)
117 117
118 def test_reflinks(self):
119 """
120 Tests that reflinks are parsed within post and connecting replies
121 to the replied posts.
122
123 Local reflink example: [post]123[/post]
124 Global reflink example: [post]key_type::key::123[/post]
125 """
126
127 key = KeyPair.objects.generate_key(primary=True)
128
129 tag = Tag.objects.create(name='test_tag')
130
131 post = Post.objects.create_post(title='', text='', tags=[tag])
132 post_local_reflink = Post.objects.create_post(title='',
133 text='[post]%d[/post]' % post.id, thread=post.get_thread())
134
135 self.assertTrue(post_local_reflink in post.referenced_posts.all(),
136 'Local reflink not connecting posts.')
137
138
118 139 def test_thread_replies(self):
119 140 """
120 141 Tests that the replies can be queried from a thread in all possible
121 142 ways.
122 143 """
123 144
124 145 tag = Tag.objects.create(name='test_tag')
125 146 opening_post = Post.objects.create_post(title='title', text='text',
126 147 tags=[tag])
127 148 thread = opening_post.get_thread()
128 149
129 150 Post.objects.create_post(title='title', text='text', thread=thread)
130 151 Post.objects.create_post(title='title', text='text', thread=thread)
131 152
132 153 replies = thread.get_replies()
133 154 self.assertTrue(len(replies) > 0, 'No replies found for thread.')
134 155
135 156 replies = thread.get_replies(view_fields_only=True)
136 157 self.assertTrue(len(replies) > 0,
137 158 'No replies found for thread with view fields only.')
138 159
139 160 def test_bumplimit(self):
140 161 """
141 162 Tests that the thread bumpable status is changed and post uids and
142 163 last update times are updated across all post threads.
143 164 """
144 165
145 166 op1 = Post.objects.create_post(title='title', text='text')
146 167 op2 = Post.objects.create_post(title='title', text='text')
147 168
148 169 thread1 = op1.get_thread()
149 170 thread1.max_posts = 5
150 171 thread1.save()
151 172
152 173 uid_1 = op1.uid
153 174 uid_2 = op2.uid
154 175
155 176 # Create multi reply
156 177 Post.objects.create_post(
157 178 title='title', text='text', thread=thread1,
158 179 opening_posts=[op1, op2])
159 180 thread_update_time_2 = op2.get_thread().last_edit_time
160 181 for i in range(6):
161 182 Post.objects.create_post(title='title', text='text',
162 183 thread=thread1)
163 184
164 185 self.assertFalse(op1.get_thread().can_bump(),
165 186 'Thread is bumpable when it should not be.')
166 187 self.assertTrue(op2.get_thread().can_bump(),
167 188 'Thread is not bumpable when it should be.')
168 189 self.assertNotEqual(
169 190 uid_1, Post.objects.get(id=op1.id).uid,
170 191 'UID of the first OP should be changed but it is not.')
171 192 self.assertEqual(
172 193 uid_2, Post.objects.get(id=op2.id).uid,
173 194 'UID of the first OP should not be changed but it is.')
174 195
175 196 self.assertNotEqual(
176 197 thread_update_time_2,
177 198 Thread.objects.get(id=op2.get_thread().id).last_edit_time,
178 199 'Thread last update time should change when the other thread '
179 200 'changes status.')
@@ -1,45 +1,46 b''
1 1 import logging
2 2 from django.core.urlresolvers import reverse, NoReverseMatch
3 3 from django.test import TestCase, Client
4 4 from boards import urls
5 5
6 6
7 7 logger = logging.getLogger(__name__)
8 8
9 9 HTTP_CODE_OK = 200
10 10
11 11 EXCLUDED_VIEWS = {
12 'banned',
13 'get_thread_diff',
12 'banned',
13 'get_thread_diff',
14 'api_sync_pull',
14 15 }
15 16
16 17
17 18 class ViewTest(TestCase):
18 19
19 20 def test_all_views(self):
20 21 """
21 22 Try opening all views defined in ulrs.py that don't need additional
22 23 parameters
23 24 """
24 25
25 26 client = Client()
26 27 for url in urls.urlpatterns:
27 28 try:
28 29 view_name = url.name
29 30 if view_name in EXCLUDED_VIEWS:
30 31 logger.debug('View {} is excluded.'.format(view_name))
31 32 continue
32 33
33 34 logger.debug('Testing view %s' % view_name)
34 35
35 36 try:
36 37 response = client.get(reverse(view_name))
37 38
38 39 self.assertEqual(HTTP_CODE_OK, response.status_code,
39 40 'View not opened: {}'.format(view_name))
40 41 except NoReverseMatch:
41 42 # This view just needs additional arguments
42 43 pass
43 44 except AttributeError:
44 45 # This is normal, some views do not have names
45 46 pass
@@ -1,89 +1,99 b''
1 1 from django.conf.urls import url
2 2 #from django.views.i18n import javascript_catalog
3 3
4 4 import neboard
5
5 6 from boards import views
6 7 from boards.rss import AllThreadsFeed, TagThreadsFeed, ThreadPostsFeed
7 8 from boards.views import api, tag_threads, all_threads, \
8 9 settings, all_tags, feed
9 10 from boards.views.authors import AuthorsView
10 11 from boards.views.notifications import NotificationView
12 from boards.views.search import BoardSearchView
11 13 from boards.views.static import StaticPageView
12 14 from boards.views.preview import PostPreviewView
15 from boards.views.sync import get_post_sync_data, response_get, response_pull
13 16 from boards.views.random import RandomImageView
14 17 from boards.views.tag_gallery import TagGalleryView
15 18 from boards.views.translation import cached_javascript_catalog
16 19
17 20
18 21 js_info_dict = {
19 22 'packages': ('boards',),
20 23 }
21 24
22 25 urlpatterns = [
23 26 # /boards/
24 27 url(r'^$', all_threads.AllThreadsView.as_view(), name='index'),
25 28
26 29 # /boards/tag/tag_name/
27 30 url(r'^tag/(?P<tag_name>\w+)/$', tag_threads.TagView.as_view(),
28 31 name='tag'),
29 32
30 33 # /boards/thread/
31 34 url(r'^thread/(?P<post_id>\d+)/$', views.thread.NormalThreadView.as_view(),
32 35 name='thread'),
33 36 url(r'^thread/(?P<post_id>\d+)/mode/gallery/$', views.thread.GalleryThreadView.as_view(),
34 37 name='thread_gallery'),
35 38 url(r'^thread/(?P<post_id>\d+)/mode/tree/$', views.thread.TreeThreadView.as_view(),
36 39 name='thread_tree'),
37 40 # /feed/
38 41 url(r'^feed/$', views.feed.FeedView.as_view(), name='feed'),
39 42
40 43 url(r'^settings/$', settings.SettingsView.as_view(), name='settings'),
41 44 url(r'^tags/(?P<query>\w+)?/?$', all_tags.AllTagsView.as_view(), name='tags'),
42 45 url(r'^authors/$', AuthorsView.as_view(), name='authors'),
43 46
44 47 url(r'^banned/$', views.banned.BannedView.as_view(), name='banned'),
45 48 url(r'^staticpage/(?P<name>\w+)/$', StaticPageView.as_view(),
46 49 name='staticpage'),
47 50
48 51 url(r'^random/$', RandomImageView.as_view(), name='random'),
49 52 url(r'^tag/(?P<tag_name>\w+)/gallery/$', TagGalleryView.as_view(), name='tag_gallery'),
50 53
51 54 # RSS feeds
52 55 url(r'^rss/$', AllThreadsFeed()),
53 56 url(r'^page/(?P<page>\d+)/rss/$', AllThreadsFeed()),
54 57 url(r'^tag/(?P<tag_name>\w+)/rss/$', TagThreadsFeed()),
55 58 url(r'^tag/(?P<tag_name>\w+)/page/(?P<page>\w+)/rss/$', TagThreadsFeed()),
56 59 url(r'^thread/(?P<post_id>\d+)/rss/$', ThreadPostsFeed()),
57 60
58 61 # i18n
59 62 url(r'^jsi18n/$', cached_javascript_catalog, js_info_dict,
60 63 name='js_info_dict'),
61 64
62 65 # API
63 66 url(r'^api/post/(?P<post_id>\d+)/$', api.get_post, name="get_post"),
64 67 url(r'^api/diff_thread/$', api.api_get_threaddiff, name="get_thread_diff"),
65 68 url(r'^api/threads/(?P<count>\w+)/$', api.api_get_threads,
66 69 name='get_threads'),
67 70 url(r'^api/tags/$', api.api_get_tags, name='get_tags'),
68 71 url(r'^api/thread/(?P<opening_post_id>\w+)/$', api.api_get_thread_posts,
69 72 name='get_thread'),
70 73 url(r'^api/add_post/(?P<opening_post_id>\w+)/$', api.api_add_post,
71 74 name='add_post'),
72 75 url(r'^api/notifications/(?P<username>\w+)/$', api.api_get_notifications,
73 76 name='api_notifications'),
74 77 url(r'^api/preview/$', api.api_get_preview, name='preview'),
75 78 url(r'^api/new_posts/$', api.api_get_new_posts, name='new_posts'),
76 79
80 # Sync protocol API
81 url(r'^api/sync/pull/$', response_pull, name='api_sync_pull'),
82 url(r'^api/sync/get/$', response_get, name='api_sync_pull'),
83 # TODO 'get' request
84
77 85 # Notifications
78 86 url(r'^notifications/(?P<username>\w+)/$', NotificationView.as_view(), name='notifications'),
79 87 url(r'^notifications/$', NotificationView.as_view(), name='notifications'),
80 88
81 89 # Post preview
82 90 url(r'^preview/$', PostPreviewView.as_view(), name='preview'),
91 url(r'^post_xml/(?P<post_id>\d+)$', get_post_sync_data,
92 name='post_sync_data'),
83 93 ]
84 94
85 95 # Search
86 96 if 'haystack' in neboard.settings.INSTALLED_APPS:
87 97 from boards.views.search import BoardSearchView
88 98 urlpatterns.append(url(r'^search/$', BoardSearchView.as_view(), name='search'))
89 99
@@ -1,301 +1,295 b''
1 from collections import OrderedDict
2 1 import json
3 2 import logging
4 3
4 from django.core import serializers
5 5 from django.db import transaction
6 from django.db.models import Count
7 6 from django.http import HttpResponse
8 7 from django.shortcuts import get_object_or_404
9 from django.core import serializers
10 from django.template.context_processors import csrf
11 8 from django.views.decorators.csrf import csrf_protect
12 9
13 from boards.abstracts.settingsmanager import get_settings_manager,\
14 FAV_THREAD_NO_UPDATES
15
10 from boards.abstracts.settingsmanager import get_settings_manager
16 11 from boards.forms import PostForm, PlainErrorList
12 from boards.mdx_neboard import Parser
17 13 from boards.models import Post, Thread, Tag
18 14 from boards.models.thread import STATUS_ARCHIVE
15 from boards.models.user import Notification
19 16 from boards.utils import datetime_to_epoch
20 17 from boards.views.thread import ThreadView
21 from boards.models.user import Notification
22 from boards.mdx_neboard import Parser
23
24 18
25 19 __author__ = 'neko259'
26 20
27 21 PARAMETER_TRUNCATED = 'truncated'
28 22 PARAMETER_TAG = 'tag'
29 23 PARAMETER_OFFSET = 'offset'
30 24 PARAMETER_DIFF_TYPE = 'type'
31 25 PARAMETER_POST = 'post'
32 26 PARAMETER_UPDATED = 'updated'
33 27 PARAMETER_LAST_UPDATE = 'last_update'
34 28 PARAMETER_THREAD = 'thread'
35 29 PARAMETER_UIDS = 'uids'
36 30
37 31 DIFF_TYPE_HTML = 'html'
38 32 DIFF_TYPE_JSON = 'json'
39 33
40 34 STATUS_OK = 'ok'
41 35 STATUS_ERROR = 'error'
42 36
43 37 logger = logging.getLogger(__name__)
44 38
45 39
46 40 @transaction.atomic
47 41 def api_get_threaddiff(request):
48 42 """
49 43 Gets posts that were changed or added since time
50 44 """
51 45
52 46 thread_id = request.POST.get(PARAMETER_THREAD)
53 47 uids_str = request.POST.get(PARAMETER_UIDS)
54 48
55 49 if not thread_id or not uids_str:
56 50 return HttpResponse(content='Invalid request.')
57 51
58 52 uids = uids_str.strip().split(' ')
59 53
60 54 opening_post = get_object_or_404(Post, id=thread_id)
61 55 thread = opening_post.get_thread()
62 56
63 57 json_data = {
64 58 PARAMETER_UPDATED: [],
65 59 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
66 60 }
67 61 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
68 62
69 63 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
70 64
71 65 for post in posts:
72 66 json_data[PARAMETER_UPDATED].append(post.get_post_data(
73 67 format_type=diff_type, request=request))
74 68 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
75 69
76 70 # If the tag is favorite, update the counter
77 71 settings_manager = get_settings_manager(request)
78 72 favorite = settings_manager.thread_is_fav(opening_post)
79 73 if favorite:
80 74 settings_manager.add_or_read_fav_thread(opening_post)
81 75
82 76 return HttpResponse(content=json.dumps(json_data))
83 77
84 78
85 79 @csrf_protect
86 80 def api_add_post(request, opening_post_id):
87 81 """
88 82 Adds a post and return the JSON response for it
89 83 """
90 84
91 85 opening_post = get_object_or_404(Post, id=opening_post_id)
92 86
93 87 logger.info('Adding post via api...')
94 88
95 89 status = STATUS_OK
96 90 errors = []
97 91
98 92 if request.method == 'POST':
99 93 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
100 94 form.session = request.session
101 95
102 96 if form.need_to_ban:
103 97 # Ban user because he is suspected to be a bot
104 98 # _ban_current_user(request)
105 99 status = STATUS_ERROR
106 100 if form.is_valid():
107 101 post = ThreadView().new_post(request, form, opening_post,
108 102 html_response=False)
109 103 if not post:
110 104 status = STATUS_ERROR
111 105 else:
112 106 logger.info('Added post #%d via api.' % post.id)
113 107 else:
114 108 status = STATUS_ERROR
115 109 errors = form.as_json_errors()
116 110
117 111 response = {
118 112 'status': status,
119 113 'errors': errors,
120 114 }
121 115
122 116 return HttpResponse(content=json.dumps(response))
123 117
124 118
125 119 def get_post(request, post_id):
126 120 """
127 121 Gets the html of a post. Used for popups. Post can be truncated if used
128 122 in threads list with 'truncated' get parameter.
129 123 """
130 124
131 125 post = get_object_or_404(Post, id=post_id)
132 126 truncated = PARAMETER_TRUNCATED in request.GET
133 127
134 128 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
135 129
136 130
137 131 def api_get_threads(request, count):
138 132 """
139 133 Gets the JSON thread opening posts list.
140 134 Parameters that can be used for filtering:
141 135 tag, offset (from which thread to get results)
142 136 """
143 137
144 138 if PARAMETER_TAG in request.GET:
145 139 tag_name = request.GET[PARAMETER_TAG]
146 140 if tag_name is not None:
147 141 tag = get_object_or_404(Tag, name=tag_name)
148 142 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
149 143 else:
150 144 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
151 145
152 146 if PARAMETER_OFFSET in request.GET:
153 147 offset = request.GET[PARAMETER_OFFSET]
154 148 offset = int(offset) if offset is not None else 0
155 149 else:
156 150 offset = 0
157 151
158 152 threads = threads.order_by('-bump_time')
159 153 threads = threads[offset:offset + int(count)]
160 154
161 155 opening_posts = []
162 156 for thread in threads:
163 157 opening_post = thread.get_opening_post()
164 158
165 159 # TODO Add tags, replies and images count
166 160 post_data = opening_post.get_post_data(include_last_update=True)
167 161 post_data['status'] = thread.get_status()
168 162
169 163 opening_posts.append(post_data)
170 164
171 165 return HttpResponse(content=json.dumps(opening_posts))
172 166
173 167
174 168 # TODO Test this
175 169 def api_get_tags(request):
176 170 """
177 171 Gets all tags or user tags.
178 172 """
179 173
180 174 # TODO Get favorite tags for the given user ID
181 175
182 176 tags = Tag.objects.get_not_empty_tags()
183 177
184 178 term = request.GET.get('term')
185 179 if term is not None:
186 180 tags = tags.filter(name__contains=term)
187 181
188 182 tag_names = [tag.name for tag in tags]
189 183
190 184 return HttpResponse(content=json.dumps(tag_names))
191 185
192 186
193 187 # TODO The result can be cached by the thread last update time
194 188 # TODO Test this
195 189 def api_get_thread_posts(request, opening_post_id):
196 190 """
197 191 Gets the JSON array of thread posts
198 192 """
199 193
200 194 opening_post = get_object_or_404(Post, id=opening_post_id)
201 195 thread = opening_post.get_thread()
202 196 posts = thread.get_replies()
203 197
204 198 json_data = {
205 199 'posts': [],
206 200 'last_update': None,
207 201 }
208 202 json_post_list = []
209 203
210 204 for post in posts:
211 205 json_post_list.append(post.get_post_data())
212 206 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
213 207 json_data['posts'] = json_post_list
214 208
215 209 return HttpResponse(content=json.dumps(json_data))
216 210
217 211
218 212 def api_get_notifications(request, username):
219 213 last_notification_id_str = request.GET.get('last', None)
220 214 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
221 215
222 216 posts = Notification.objects.get_notification_posts(usernames=[username],
223 217 last=last_id)
224 218
225 219 json_post_list = []
226 220 for post in posts:
227 221 json_post_list.append(post.get_post_data())
228 222 return HttpResponse(content=json.dumps(json_post_list))
229 223
230 224
231 225 def api_get_post(request, post_id):
232 226 """
233 227 Gets the JSON of a post. This can be
234 228 used as and API for external clients.
235 229 """
236 230
237 231 post = get_object_or_404(Post, id=post_id)
238 232
239 233 json = serializers.serialize("json", [post], fields=(
240 234 "pub_time", "_text_rendered", "title", "text", "image",
241 235 "image_width", "image_height", "replies", "tags"
242 236 ))
243 237
244 238 return HttpResponse(content=json)
245 239
246 240
247 241 def api_get_preview(request):
248 242 raw_text = request.POST['raw_text']
249 243
250 244 parser = Parser()
251 245 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
252 246
253 247
254 248 def api_get_new_posts(request):
255 249 """
256 250 Gets favorite threads and unread posts count.
257 251 """
258 252 posts = list()
259 253
260 254 include_posts = 'include_posts' in request.GET
261 255
262 256 settings_manager = get_settings_manager(request)
263 257 fav_threads = settings_manager.get_fav_threads()
264 258 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
265 259 .order_by('-pub_time').prefetch_related('thread')
266 260
267 261 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
268 262 if include_posts:
269 263 new_post_threads = Thread.objects.get_new_posts(ops)
270 264 if new_post_threads:
271 265 thread_ids = {thread.id: thread for thread in new_post_threads}
272 266 else:
273 267 thread_ids = dict()
274 268
275 269 for op in fav_thread_ops:
276 270 fav_thread_dict = dict()
277 271
278 272 op_thread = op.get_thread()
279 273 if op_thread.id in thread_ids:
280 274 thread = thread_ids[op_thread.id]
281 275 new_post_count = thread.new_post_count
282 276 fav_thread_dict['newest_post_link'] = thread.get_replies()\
283 277 .filter(id__gt=fav_threads[str(op.id)])\
284 278 .first().get_absolute_url(thread=thread)
285 279 else:
286 280 new_post_count = 0
287 281 fav_thread_dict['new_post_count'] = new_post_count
288 282
289 283 fav_thread_dict['id'] = op.id
290 284
291 285 fav_thread_dict['post_url'] = op.get_link_view()
292 286 fav_thread_dict['title'] = op.title
293 287
294 288 posts.append(fav_thread_dict)
295 289 else:
296 290 fav_thread_dict = dict()
297 291 fav_thread_dict['new_post_count'] = \
298 292 Thread.objects.get_new_post_count(ops)
299 293 posts.append(fav_thread_dict)
300 294
301 295 return HttpResponse(content=json.dumps(posts))
@@ -1,241 +1,232 b''
1 1 # Django settings for neboard project.
2 2 import os
3 3
4 4 DEBUG = True
5 TEMPLATE_DEBUG = DEBUG
6 5
7 6 ADMINS = (
8 7 # ('Your Name', 'your_email@example.com'),
9 8 ('admin', 'admin@example.com')
10 9 )
11 10
12 11 MANAGERS = ADMINS
13 12
14 13 DATABASES = {
15 14 'default': {
16 15 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
17 16 'NAME': 'database.db', # Or path to database file if using sqlite3.
18 17 'USER': '', # Not used with sqlite3.
19 18 'PASSWORD': '', # Not used with sqlite3.
20 19 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
21 20 'PORT': '', # Set to empty string for default. Not used with sqlite3.
22 21 'CONN_MAX_AGE': None,
23 22 }
24 23 }
25 24
26 25 # Local time zone for this installation. Choices can be found here:
27 26 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
28 27 # although not all choices may be available on all operating systems.
29 28 # In a Windows environment this must be set to your system time zone.
30 29 TIME_ZONE = 'Europe/Kiev'
31 30
32 31 # Language code for this installation. All choices can be found here:
33 32 # http://www.i18nguy.com/unicode/language-identifiers.html
34 33 LANGUAGE_CODE = 'en'
35 34
36 35 SITE_ID = 1
37 36
38 37 # If you set this to False, Django will make some optimizations so as not
39 38 # to load the internationalization machinery.
40 39 USE_I18N = True
41 40
42 41 # If you set this to False, Django will not format dates, numbers and
43 42 # calendars according to the current locale.
44 43 USE_L10N = True
45 44
46 45 # If you set this to False, Django will not use timezone-aware datetimes.
47 46 USE_TZ = True
48 47
49 48 USE_ETAGS = True
50 49
51 50 # Absolute filesystem path to the directory that will hold user-uploaded files.
52 51 # Example: "/home/media/media.lawrence.com/media/"
53 52 MEDIA_ROOT = './media/'
54 53
55 54 # URL that handles the media served from MEDIA_ROOT. Make sure to use a
56 55 # trailing slash.
57 56 # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
58 57 MEDIA_URL = '/media/'
59 58
60 59 # Absolute path to the directory static files should be collected to.
61 60 # Don't put anything in this directory yourself; store your static files
62 61 # in apps' "static/" subdirectories and in STATICFILES_DIRS.
63 62 # Example: "/home/media/media.lawrence.com/static/"
64 63 STATIC_ROOT = ''
65 64
66 65 # URL prefix for static files.
67 66 # Example: "http://media.lawrence.com/static/"
68 67 STATIC_URL = '/static/'
69 68
70 # Additional locations of static files
71 # It is really a hack, put real paths, not related
72 STATICFILES_DIRS = (
73 os.path.dirname(__file__) + '/boards/static',
74
75 # '/d/work/python/django/neboard/neboard/boards/static',
76 # Put strings here, like "/home/html/static" or "C:/www/django/static".
77 # Always use forward slashes, even on Windows.
78 # Don't forget to use absolute paths, not relative paths.
79 )
69 STATICFILES_DIRS = []
80 70
81 71 # List of finder classes that know how to find static files in
82 72 # various locations.
83 73 STATICFILES_FINDERS = (
84 74 'django.contrib.staticfiles.finders.FileSystemFinder',
85 75 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
86 76 )
87 77
88 78 if DEBUG:
89 79 STATICFILES_STORAGE = \
90 80 'django.contrib.staticfiles.storage.StaticFilesStorage'
91 81 else:
92 82 STATICFILES_STORAGE = \
93 83 'django.contrib.staticfiles.storage.CachedStaticFilesStorage'
94 84
95 85 # Make this unique, and don't share it with anybody.
96 86 SECRET_KEY = '@1rc$o(7=tt#kd+4s$u6wchm**z^)4x90)7f6z(i&amp;55@o11*8o'
97 87
98 88 TEMPLATES = [{
99 89 'BACKEND': 'django.template.backends.django.DjangoTemplates',
100 90 'DIRS': ['templates'],
101 91 'OPTIONS': {
102 92 'loaders': [
103 93 ('django.template.loaders.cached.Loader', [
104 94 'django.template.loaders.filesystem.Loader',
105 95 'django.template.loaders.app_directories.Loader',
106 96 ]),
107 97 ],
108 98 'context_processors': [
109 99 'django.template.context_processors.csrf',
110 100 'django.core.context_processors.media',
111 101 'django.core.context_processors.static',
112 102 'django.core.context_processors.request',
113 103 'django.contrib.auth.context_processors.auth',
114 104 'boards.context_processors.user_and_ui_processor',
115 105 ],
116 106 },
117 107 }]
118 108
119 109
120 110 MIDDLEWARE_CLASSES = [
121 111 'django.middleware.http.ConditionalGetMiddleware',
122 112 'django.contrib.sessions.middleware.SessionMiddleware',
123 113 'django.middleware.locale.LocaleMiddleware',
124 114 'django.middleware.common.CommonMiddleware',
125 115 'django.contrib.auth.middleware.AuthenticationMiddleware',
126 116 'django.contrib.messages.middleware.MessageMiddleware',
127 117 'boards.middlewares.BanMiddleware',
128 118 'boards.middlewares.TimezoneMiddleware',
129 119 ]
130 120
131 121 ROOT_URLCONF = 'neboard.urls'
132 122
133 123 # Python dotted path to the WSGI application used by Django's runserver.
134 124 WSGI_APPLICATION = 'neboard.wsgi.application'
135 125
136 126 INSTALLED_APPS = (
137 127 'django.contrib.auth',
138 128 'django.contrib.contenttypes',
139 129 'django.contrib.sessions',
140 130 # 'django.contrib.sites',
141 131 'django.contrib.messages',
142 132 'django.contrib.staticfiles',
143 133 # Uncomment the next line to enable the admin:
144 134 'django.contrib.admin',
145 135 # Uncomment the next line to enable admin documentation:
146 136 # 'django.contrib.admindocs',
147 137 #'django.contrib.humanize',
148 138
149 139 'debug_toolbar',
150 140
151 141 # Search
152 142 'haystack',
153 143
154 144 'boards',
155 145 )
156 146
157 147 # A sample logging configuration. The only tangible logging
158 148 # performed by this configuration is to send an email to
159 149 # the site admins on every HTTP 500 error when DEBUG=False.
160 150 # See http://docs.djangoproject.com/en/dev/topics/logging for
161 151 # more details on how to customize your logging configuration.
162 152 LOGGING = {
163 153 'version': 1,
164 154 'disable_existing_loggers': False,
165 155 'formatters': {
166 156 'verbose': {
167 157 'format': '%(levelname)s %(asctime)s %(name)s %(process)d %(thread)d %(message)s'
168 158 },
169 159 'simple': {
170 160 'format': '%(levelname)s %(asctime)s [%(name)s] %(message)s'
171 161 },
172 162 },
173 163 'filters': {
174 164 'require_debug_false': {
175 165 '()': 'django.utils.log.RequireDebugFalse'
176 166 }
177 167 },
178 168 'handlers': {
179 169 'console': {
180 170 'level': 'DEBUG',
181 171 'class': 'logging.StreamHandler',
182 172 'formatter': 'simple'
183 173 },
184 174 },
185 175 'loggers': {
186 176 'boards': {
187 177 'handlers': ['console'],
188 178 'level': 'DEBUG',
189 179 }
190 180 },
191 181 }
192 182
193 183 HAYSTACK_CONNECTIONS = {
194 184 'default': {
195 185 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
196 186 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
197 187 },
198 188 }
199 189
200 190 THEMES = [
201 191 ('md', 'Mystic Dark'),
202 192 ('md_centered', 'Mystic Dark (centered)'),
203 193 ('sw', 'Snow White'),
204 194 ('pg', 'Photon Gray'),
205 195 ]
206 196
207 197 IMAGE_VIEWERS = [
208 198 ('simple', 'Simple'),
209 199 ('popup', 'Popup'),
210 200 ]
211 201
212 202 ALLOWED_HOSTS = ['*']
213 203
214 204 POSTING_DELAY = 20 # seconds
215 205
216 206 # Websocket settins
217 207 CENTRIFUGE_HOST = 'localhost'
218 208 CENTRIFUGE_PORT = '9090'
219 209
220 210 CENTRIFUGE_ADDRESS = 'http://{}:{}'.format(CENTRIFUGE_HOST, CENTRIFUGE_PORT)
221 211 CENTRIFUGE_PROJECT_ID = '<project id here>'
222 212 CENTRIFUGE_PROJECT_SECRET = '<project secret here>'
223 213 CENTRIFUGE_TIMEOUT = 5
224 214
225 215 # Debug middlewares
226 216 MIDDLEWARE_CLASSES += [
227 217 'debug_toolbar.middleware.DebugToolbarMiddleware',
228 218 ]
229 219
220
230 221 def custom_show_toolbar(request):
231 222 return request.user.has_perm('admin.debug')
232 223
233 224 DEBUG_TOOLBAR_CONFIG = {
234 225 'ENABLE_STACKTRACES': True,
235 226 'SHOW_TOOLBAR_CALLBACK': 'neboard.settings.custom_show_toolbar',
236 227 }
237 228
238 229 # FIXME Uncommenting this fails somehow. Need to investigate this
239 230 #DEBUG_TOOLBAR_PANELS += (
240 231 # 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
241 232 #)
@@ -1,37 +1,70 b''
1 1 # INTRO #
2 2
3 3 This project aims to create centralized forum-like discussion platform with
4 4 anonymity in mind.
5 5
6 6 Main repository: https://bitbucket.org/neko259/neboard/
7 7
8 8 Site: http://neboard.me/
9 9
10 10 # INSTALLATION #
11 11
12 1. Install all dependencies over pip or system-wide
13 2. Setup a database in `neboard/settings.py`
14 3. Run `./manage.py migrate` to apply all south migrations
15 4. Apply config changes to `boards/config/config.ini`. You can see the default settings in `boards/config/default_config.ini`
12 1. Download application and move inside it:
13
14 `hg clone https://bitbucket.org/neko259/neboard`
15
16 `cd neboard`
17
18 If you wish to use *decentral* version, change branch to *decentral*:
19
20 `hg up decentral`
21
22 2. Install all application dependencies:
23
24 Some minimal system-wide depenencies:
25
26 * python3
27 * pip/pip3
28 * jpeg
29
30 Python dependencies:
31
32 `pip3 install -r requirements.txt`
33
34 You can use virtualenv to speed up the process or avoid conflicts.
35
36 3. Setup a database in `neboard/settings.py`. You can also change other settings like search engine.
37
38 Depending on configured database and search engine, you need to install corresponding dependencies manually.
39
40 Default database is *sqlite*, default search engine is *whoosh*.
41
42 4. Setup SECRET_KEY to a secret value in `neboard/settings.py
43 5. Run `./manage.py migrate` to apply all migrations
44 6. Apply config changes to `boards/config/config.ini`. You can see the default settings in `boards/config/default_config.ini`
45 7. If you want to use decetral engine, run `./manage.py generate_keypair` to generate keys
16 46
17 47 # RUNNING #
18 48
19 You can run the server using django default embedded webserver by running
49 You can run the server using django default embedded webserver by running:
20 50
21 51 ./manage.py runserver <address>:<port>
22 52
23 See django-admin command help for details
53 See django-admin command help for details.
24 54
25 55 Also consider using wsgi or fcgi interfaces on production servers.
26 56
57 When running for the first time, you need to setup at least one section tag.
58 Go to the admin page and manually create one tag with "required" property set.
59
27 60 # UPGRADE #
28 61
29 62 1. Backup your project data.
30 63 2. Copy the project contents over the old project directory
31 64 3. Run migrations by `./manage.py migrate`
32 65
33 66 You can also just clone the mercurial project and pull it to update
34 67
35 68 # CONCLUSION #
36 69
37 Enjoy our software and thank you!
70 Enjoy our software and thank you! No newline at end of file
@@ -1,10 +1,13 b''
1 1 python-magic
2 httplib2
3 simplejson
2 4 pytube
3 5 requests
4 6 adjacent
5 7 django-haystack
6 8 pillow
7 9 django>=1.8
8 10 bbcode
9 11 django-debug-toolbar
10 12 pytz
13 ecdsa No newline at end of file
@@ -1,26 +1,27 b''
1 1 = Features =
2 2 * Tree view (JS)
3 3 * Adding tags to images filename
4 4 * Federative network for s2s communication
5 5 * XMPP gate
6 6 * Bitmessage gate
7 7 * Notification engine
8 8 * Group tags by first letter in all tags list
9 9 * [JS] Character counter in the post field
10 10 * Statistics module. Count views (optional, may result in bad
11 11 performance), posts per day/week/month, IPs
12 12 * Ban confirmation page (or alert) with reason
13 13 * Post deletion confirmation page (or alert)
14 14 * Get thread graph image using pygraphviz
15 15 * Subscribing to tag via AJAX
16 16 * Add buttons to insert a named link or a named quote to the markup panel
17 17 * Add support for "attention posts" that are shown in the header"
18 * Use absolute post reflinks in the raw text
18 19 * Use default boards/settings when it is not defined. Maybe default_settings.py module?
19 20
20 21 = Bugs =
21 22 * Search sort order is confusing
22 23
23 24 = Testing =
24 25 * Make tests for every view
25 26 * Make tests for every model
26 27 * Make tests for every form
General Comments 0
You need to be logged in to leave comments. Login now