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