Show More
@@ -0,0 +1,19 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', '0021_tag_description'), | |
|
11 | ] | |
|
12 | ||
|
13 | operations = [ | |
|
14 | migrations.AlterField( | |
|
15 | model_name='thread', | |
|
16 | name='tags', | |
|
17 | field=models.ManyToManyField(related_name='thread_tags', to='boards.Tag'), | |
|
18 | ), | |
|
19 | ] |
@@ -0,0 +1,29 b'' | |||
|
1 | # -*- coding: utf-8 -*- | |
|
2 | from __future__ import unicode_literals | |
|
3 | ||
|
4 | from django.db import models, migrations | |
|
5 | import boards.models.attachment | |
|
6 | ||
|
7 | ||
|
8 | class Migration(migrations.Migration): | |
|
9 | ||
|
10 | dependencies = [ | |
|
11 | ('boards', '0022_auto_20150812_1819'), | |
|
12 | ] | |
|
13 | ||
|
14 | operations = [ | |
|
15 | migrations.CreateModel( | |
|
16 | name='Attachment', | |
|
17 | fields=[ | |
|
18 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), | |
|
19 | ('file', models.FileField(upload_to=boards.models.attachment.Attachment._update_filename)), | |
|
20 | ('mimetype', models.CharField(max_length=50)), | |
|
21 | ('hash', models.CharField(max_length=36)), | |
|
22 | ], | |
|
23 | ), | |
|
24 | migrations.AddField( | |
|
25 | model_name='post', | |
|
26 | name='attachments', | |
|
27 | field=models.ManyToManyField(blank=True, null=True, related_name='attachment_posts', to='boards.Attachment'), | |
|
28 | ), | |
|
29 | ] |
@@ -0,0 +1,75 b'' | |||
|
1 | import hashlib | |
|
2 | import os | |
|
3 | import time | |
|
4 | ||
|
5 | from random import random | |
|
6 | ||
|
7 | from django.db import models | |
|
8 | ||
|
9 | from boards.models.attachment.viewers import AbstractViewer, WebmViewer | |
|
10 | ||
|
11 | ||
|
12 | FILES_DIRECTORY = 'files/' | |
|
13 | FILE_EXTENSION_DELIMITER = '.' | |
|
14 | ||
|
15 | VIEWERS = ( | |
|
16 | WebmViewer, | |
|
17 | ) | |
|
18 | ||
|
19 | ||
|
20 | class AttachmentManager(models.Manager): | |
|
21 | def create_with_hash(self, file): | |
|
22 | file_hash = self.get_hash(file) | |
|
23 | existing = self.filter(hash=file_hash) | |
|
24 | if len(existing) > 0: | |
|
25 | attachment = existing[0] | |
|
26 | else: | |
|
27 | file_type = file.name.split(FILE_EXTENSION_DELIMITER)[-1].lower() | |
|
28 | attachment = Attachment.objects.create(file=file, | |
|
29 | mimetype=file_type, hash=file_hash) | |
|
30 | ||
|
31 | return attachment | |
|
32 | ||
|
33 | def get_hash(self, file): | |
|
34 | """ | |
|
35 | Gets hash of an file. | |
|
36 | """ | |
|
37 | md5 = hashlib.md5() | |
|
38 | for chunk in file.chunks(): | |
|
39 | md5.update(chunk) | |
|
40 | return md5.hexdigest() | |
|
41 | ||
|
42 | ||
|
43 | class Attachment(models.Model): | |
|
44 | objects = AttachmentManager() | |
|
45 | ||
|
46 | # TODO Dedup the method | |
|
47 | def _update_filename(self, filename): | |
|
48 | """ | |
|
49 | Gets unique filename | |
|
50 | """ | |
|
51 | ||
|
52 | # TODO Use something other than random number in file name | |
|
53 | new_name = '{}{}.{}'.format( | |
|
54 | str(int(time.mktime(time.gmtime()))), | |
|
55 | str(int(random() * 1000)), | |
|
56 | filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]) | |
|
57 | ||
|
58 | return os.path.join(FILES_DIRECTORY, new_name) | |
|
59 | ||
|
60 | file = models.FileField(upload_to=_update_filename) | |
|
61 | mimetype = models.CharField(max_length=50) | |
|
62 | hash = models.CharField(max_length=36) | |
|
63 | ||
|
64 | def get_view(self): | |
|
65 | file_viewer = None | |
|
66 | for viewer in VIEWERS: | |
|
67 | if viewer.supports(self.mimetype): | |
|
68 | file_viewer = viewer(self.file, self.mimetype) | |
|
69 | break | |
|
70 | if file_viewer is None: | |
|
71 | file_viewer = AbstractViewer(self.file, self.mimetype) | |
|
72 | ||
|
73 | return file_viewer.get_view() | |
|
74 | ||
|
75 |
@@ -0,0 +1,39 b'' | |||
|
1 | from django.template.defaultfilters import filesizeformat | |
|
2 | ||
|
3 | ||
|
4 | class AbstractViewer: | |
|
5 | def __init__(self, file, file_type): | |
|
6 | self.file = file | |
|
7 | self.file_type = file_type | |
|
8 | ||
|
9 | @staticmethod | |
|
10 | def get_viewer(file_type, file): | |
|
11 | for viewer in VIEWERS: | |
|
12 | if viewer.supports(file_type): | |
|
13 | return viewer(file) | |
|
14 | return AbstractViewer(file) | |
|
15 | ||
|
16 | @staticmethod | |
|
17 | def supports(file_type): | |
|
18 | return true | |
|
19 | ||
|
20 | def get_view(self): | |
|
21 | return '<div class="image"><a href="{}">'\ | |
|
22 | '<img src="/static/images/file.png" width="200" height="150"/>'\ | |
|
23 | '</a>'\ | |
|
24 | '<div class="image-metadata">{}, {}</div>'\ | |
|
25 | '</div>'.format(self.file.url, self.file_type, | |
|
26 | filesizeformat(self.file.size)) | |
|
27 | ||
|
28 | ||
|
29 | class WebmViewer(AbstractViewer): | |
|
30 | @staticmethod | |
|
31 | def supports(file_type): | |
|
32 | return file_type == 'webm' | |
|
33 | ||
|
34 | def get_view(self): | |
|
35 | return '<div class="image">'\ | |
|
36 | '<video width="200" height="150" controls/>'\ | |
|
37 | '<source src="{}">'\ | |
|
38 | '</div>'.format(self.file.url) | |
|
39 |
@@ -9,7 +9,7 b' CacheTimeout = 600' | |||
|
9 | 9 | [Forms] |
|
10 | 10 | # Max post length in characters |
|
11 | 11 | MaxTextLength = 30000 |
|
12 |
Max |
|
|
12 | MaxFileSize = 8000000 | |
|
13 | 13 | LimitPostingSpeed = false |
|
14 | 14 | |
|
15 | 15 | [Messages] |
@@ -18,14 +18,6 b' import boards.settings as board_settings' | |||
|
18 | 18 | HEADER_CONTENT_LENGTH = 'content-length' |
|
19 | 19 | HEADER_CONTENT_TYPE = 'content-type' |
|
20 | 20 | |
|
21 | CONTENT_TYPE_IMAGE = ( | |
|
22 | 'image/jpeg', | |
|
23 | 'image/jpg', | |
|
24 | 'image/png', | |
|
25 | 'image/gif', | |
|
26 | 'image/bmp', | |
|
27 | ) | |
|
28 | ||
|
29 | 21 | REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE) |
|
30 | 22 | |
|
31 | 23 | VETERAN_POSTING_DELAY = 5 |
@@ -47,7 +39,7 b" ERROR_SPEED = _('Please wait %s seconds " | |||
|
47 | 39 | |
|
48 | 40 | TAG_MAX_LENGTH = 20 |
|
49 | 41 | |
|
50 |
|
|
|
42 | FILE_DOWNLOAD_CHUNK_BYTES = 100000 | |
|
51 | 43 | |
|
52 | 44 | HTTP_RESULT_OK = 200 |
|
53 | 45 | |
@@ -144,10 +136,10 b' class PostForm(NeboardForm):' | |||
|
144 | 136 | ATTRIBUTE_ROWS: TEXTAREA_ROWS, |
|
145 | 137 | }), |
|
146 | 138 | required=False, label=LABEL_TEXT) |
|
147 |
|
|
|
139 | file = forms.FileField(required=False, label=_('File'), | |
|
148 | 140 | widget=forms.ClearableFileInput( |
|
149 |
attrs={'accept': ' |
|
|
150 |
|
|
|
141 | attrs={'accept': 'file/*'})) | |
|
142 | file_url = forms.CharField(required=False, label=_('File URL'), | |
|
151 | 143 | widget=forms.TextInput( |
|
152 | 144 | attrs={ATTRIBUTE_PLACEHOLDER: |
|
153 | 145 | 'http://example.com/image.png'})) |
@@ -181,27 +173,27 b' class PostForm(NeboardForm):' | |||
|
181 | 173 | 'characters') % str(max_length)) |
|
182 | 174 | return text |
|
183 | 175 | |
|
184 |
def clean_ |
|
|
185 |
|
|
|
176 | def clean_file(self): | |
|
177 | file = self.cleaned_data['file'] | |
|
186 | 178 | |
|
187 |
if |
|
|
188 |
self.validate_ |
|
|
179 | if file: | |
|
180 | self.validate_file_size(file.size) | |
|
189 | 181 | |
|
190 |
return |
|
|
182 | return file | |
|
191 | 183 | |
|
192 |
def clean_ |
|
|
193 |
url = self.cleaned_data[' |
|
|
184 | def clean_file_url(self): | |
|
185 | url = self.cleaned_data['file_url'] | |
|
194 | 186 | |
|
195 |
|
|
|
187 | file = None | |
|
196 | 188 | if url: |
|
197 |
|
|
|
189 | file = self._get_file_from_url(url) | |
|
198 | 190 | |
|
199 |
if not |
|
|
191 | if not file: | |
|
200 | 192 | raise forms.ValidationError(_('Invalid URL')) |
|
201 | 193 | else: |
|
202 |
self.validate_ |
|
|
194 | self.validate_file_size(file.size) | |
|
203 | 195 | |
|
204 |
return |
|
|
196 | return file | |
|
205 | 197 | |
|
206 | 198 | def clean_threads(self): |
|
207 | 199 | threads_str = self.cleaned_data['threads'] |
@@ -230,27 +222,27 b' class PostForm(NeboardForm):' | |||
|
230 | 222 | raise forms.ValidationError('A human cannot enter a hidden field') |
|
231 | 223 | |
|
232 | 224 | if not self.errors: |
|
233 |
self._clean_text_ |
|
|
225 | self._clean_text_file() | |
|
234 | 226 | |
|
235 | 227 | if not self.errors and self.session: |
|
236 | 228 | self._validate_posting_speed() |
|
237 | 229 | |
|
238 | 230 | return cleaned_data |
|
239 | 231 | |
|
240 |
def get_ |
|
|
232 | def get_file(self): | |
|
241 | 233 | """ |
|
242 |
Gets |
|
|
234 | Gets file from form or URL. | |
|
243 | 235 | """ |
|
244 | 236 | |
|
245 |
|
|
|
246 |
return |
|
|
237 | file = self.cleaned_data['file'] | |
|
238 | return file or self.cleaned_data['file_url'] | |
|
247 | 239 | |
|
248 |
def _clean_text_ |
|
|
240 | def _clean_text_file(self): | |
|
249 | 241 | text = self.cleaned_data.get('text') |
|
250 |
|
|
|
242 | file = self.get_file() | |
|
251 | 243 | |
|
252 |
if (not text) and (not |
|
|
253 |
error_message = _('Either text or |
|
|
244 | if (not text) and (not file): | |
|
245 | error_message = _('Either text or file must be entered.') | |
|
254 | 246 | self._errors['text'] = self.error_class([error_message]) |
|
255 | 247 | |
|
256 | 248 | def _validate_posting_speed(self): |
@@ -284,16 +276,16 b' class PostForm(NeboardForm):' | |||
|
284 | 276 | if can_post: |
|
285 | 277 | self.session[LAST_POST_TIME] = now |
|
286 | 278 | |
|
287 |
def validate_ |
|
|
288 |
max_size = board_settings.get_int('Forms', 'Max |
|
|
279 | def validate_file_size(self, size: int): | |
|
280 | max_size = board_settings.get_int('Forms', 'MaxFileSize') | |
|
289 | 281 | if size > max_size: |
|
290 | 282 | raise forms.ValidationError( |
|
291 |
_(' |
|
|
283 | _('File must be less than %s bytes') | |
|
292 | 284 | % str(max_size)) |
|
293 | 285 | |
|
294 |
def _get_ |
|
|
286 | def _get_file_from_url(self, url: str) -> SimpleUploadedFile: | |
|
295 | 287 | """ |
|
296 |
Gets an |
|
|
288 | Gets an file file from URL. | |
|
297 | 289 | """ |
|
298 | 290 | |
|
299 | 291 | img_temp = None |
@@ -302,30 +294,29 b' class PostForm(NeboardForm):' | |||
|
302 | 294 | # Verify content headers |
|
303 | 295 | response_head = requests.head(url, verify=False) |
|
304 | 296 | content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0] |
|
305 | if content_type in CONTENT_TYPE_IMAGE: | |
|
306 | length_header = response_head.headers.get(HEADER_CONTENT_LENGTH) | |
|
307 |
|
|
|
308 | length = int(length_header) | |
|
309 | self.validate_image_size(length) | |
|
310 | # Get the actual content into memory | |
|
311 | response = requests.get(url, verify=False, stream=True) | |
|
297 | length_header = response_head.headers.get(HEADER_CONTENT_LENGTH) | |
|
298 | if length_header: | |
|
299 | length = int(length_header) | |
|
300 | self.validate_file_size(length) | |
|
301 | # Get the actual content into memory | |
|
302 | response = requests.get(url, verify=False, stream=True) | |
|
312 | 303 | |
|
313 |
|
|
|
314 |
|
|
|
315 |
|
|
|
316 |
|
|
|
317 |
|
|
|
318 |
|
|
|
319 |
|
|
|
304 | # Download file, stop if the size exceeds limit | |
|
305 | size = 0 | |
|
306 | content = b'' | |
|
307 | for chunk in response.iter_content(file_DOWNLOAD_CHUNK_BYTES): | |
|
308 | size += len(chunk) | |
|
309 | self.validate_file_size(size) | |
|
310 | content += chunk | |
|
320 | 311 | |
|
321 |
|
|
|
322 |
|
|
|
323 |
|
|
|
324 |
|
|
|
325 |
|
|
|
326 |
|
|
|
312 | if response.status_code == HTTP_RESULT_OK and content: | |
|
313 | # Set a dummy file name that will be replaced | |
|
314 | # anyway, just keep the valid extension | |
|
315 | filename = 'file.' + content_type.split('/')[1] | |
|
316 | img_temp = SimpleUploadedFile(filename, content, | |
|
317 | content_type) | |
|
327 | 318 | except Exception: |
|
328 |
# Just return no |
|
|
319 | # Just return no file | |
|
329 | 320 | pass |
|
330 | 321 | |
|
331 | 322 | return img_temp |
@@ -369,7 +360,7 b' class ThreadForm(PostForm):' | |||
|
369 | 360 | class SettingsForm(NeboardForm): |
|
370 | 361 | |
|
371 | 362 | theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme')) |
|
372 |
image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_(' |
|
|
363 | image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('image view mode')) | |
|
373 | 364 | username = forms.CharField(label=_('User name'), required=False) |
|
374 | 365 | timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone')) |
|
375 | 366 |
@@ -1,6 +1,7 b'' | |||
|
1 | 1 | __author__ = 'neko259' |
|
2 | 2 | |
|
3 | 3 | from boards.models.image import PostImage |
|
4 | from boards.models.attachment import Attachment | |
|
4 | 5 | from boards.models.thread import Thread |
|
5 | 6 | from boards.models.post import Post |
|
6 | 7 | from boards.models.tag import Tag |
@@ -61,15 +61,13 b' class PostImage(models.Model, Viewable):' | |||
|
61 | 61 | Gets unique image filename |
|
62 | 62 | """ |
|
63 | 63 | |
|
64 | path = IMAGES_DIRECTORY | |
|
65 | ||
|
66 | 64 | # TODO Use something other than random number in file name |
|
67 | 65 | new_name = '{}{}.{}'.format( |
|
68 | 66 | str(int(time.mktime(time.gmtime()))), |
|
69 | 67 | str(int(random() * 1000)), |
|
70 | 68 | filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]) |
|
71 | 69 | |
|
72 |
return os.path.join( |
|
|
70 | return os.path.join(IMAGES_DIRECTORY, new_name) | |
|
73 | 71 | |
|
74 | 72 | width = models.IntegerField(default=0) |
|
75 | 73 | height = models.IntegerField(default=0) |
@@ -13,7 +13,7 b' from django.utils import timezone' | |||
|
13 | 13 | |
|
14 | 14 | from boards import settings |
|
15 | 15 | from boards.mdx_neboard import Parser |
|
16 | from boards.models import PostImage | |
|
16 | from boards.models import PostImage, Attachment | |
|
17 | 17 | from boards.models.base import Viewable |
|
18 | 18 | from boards import utils |
|
19 | 19 | from boards.models.post.export import get_exporter, DIFF_TYPE_JSON |
@@ -62,10 +62,17 b' POST_VIEW_PARAMS = (' | |||
|
62 | 62 | |
|
63 | 63 | REFMAP_STR = '<a href="{}">>>{}</a>' |
|
64 | 64 | |
|
65 | IMAGE_TYPES = ( | |
|
66 | 'jpeg', | |
|
67 | 'jpg', | |
|
68 | 'png', | |
|
69 | 'bmp', | |
|
70 | ) | |
|
71 | ||
|
65 | 72 | |
|
66 | 73 | class PostManager(models.Manager): |
|
67 | 74 | @transaction.atomic |
|
68 |
def create_post(self, title: str, text: str, |
|
|
75 | def create_post(self, title: str, text: str, file=None, thread=None, | |
|
69 | 76 | ip=NO_IP, tags: list=None, opening_posts: list=None): |
|
70 | 77 | """ |
|
71 | 78 | Creates new post |
@@ -105,8 +112,13 b' class PostManager(models.Manager):' | |||
|
105 | 112 | |
|
106 | 113 | logger.info('Created post {} by {}'.format(post, post.poster_ip)) |
|
107 | 114 | |
|
108 | if image: | |
|
109 | post.images.add(PostImage.objects.create_with_hash(image)) | |
|
115 | # TODO Move this to other place | |
|
116 | if file: | |
|
117 | file_type = file.name.split('.')[-1].lower() | |
|
118 | if file_type in IMAGE_TYPES: | |
|
119 | post.images.add(PostImage.objects.create_with_hash(file)) | |
|
120 | else: | |
|
121 | post.attachments.add(Attachment.objects.create_with_hash(file)) | |
|
110 | 122 | |
|
111 | 123 | post.build_url() |
|
112 | 124 | post.connect_replies() |
@@ -169,6 +181,8 b' class Post(models.Model, Viewable):' | |||
|
169 | 181 | |
|
170 | 182 | images = models.ManyToManyField(PostImage, null=True, blank=True, |
|
171 | 183 | related_name='post_images', db_index=True) |
|
184 | attachments = models.ManyToManyField(Attachment, null=True, blank=True, | |
|
185 | related_name='attachment_posts') | |
|
172 | 186 | |
|
173 | 187 | poster_ip = models.GenericIPAddressField() |
|
174 | 188 |
@@ -51,12 +51,19 b'' | |||
|
51 | 51 | supports multiple. |
|
52 | 52 | {% endcomment %} |
|
53 | 53 | {% if post.images.exists %} |
|
54 |
{% with post.images. |
|
|
54 | {% with post.images.first as image %} | |
|
55 | 55 | {% autoescape off %} |
|
56 | 56 | {{ image.get_view }} |
|
57 | 57 | {% endautoescape %} |
|
58 | 58 | {% endwith %} |
|
59 | 59 | {% endif %} |
|
60 | {% if post.attachments.exists %} | |
|
61 | {% with post.attachments.first as file %} | |
|
62 | {% autoescape off %} | |
|
63 | {{ file.get_view }} | |
|
64 | {% endautoescape %} | |
|
65 | {% endwith %} | |
|
66 | {% endif %} | |
|
60 | 67 | {% comment %} |
|
61 | 68 | Post message (text) |
|
62 | 69 | {% endcomment %} |
@@ -141,7 +141,7 b' class AllThreadsView(PostMixin, BaseBoar' | |||
|
141 | 141 | |
|
142 | 142 | title = data[FORM_TITLE] |
|
143 | 143 | text = data[FORM_TEXT] |
|
144 |
|
|
|
144 | file = form.get_file() | |
|
145 | 145 | threads = data[FORM_THREADS] |
|
146 | 146 | |
|
147 | 147 | text = self._remove_invalid_links(text) |
@@ -150,7 +150,7 b' class AllThreadsView(PostMixin, BaseBoar' | |||
|
150 | 150 | |
|
151 | 151 | tags = self.parse_tags_string(tag_strings) |
|
152 | 152 | |
|
153 |
post = Post.objects.create_post(title=title, text=text, |
|
|
153 | post = Post.objects.create_post(title=title, text=text, file=file, | |
|
154 | 154 | ip=ip, tags=tags, opening_posts=threads) |
|
155 | 155 | |
|
156 | 156 | # This is required to update the threads to which posts we have replied |
@@ -102,14 +102,14 b' class ThreadView(BaseBoardView, PostMixi' | |||
|
102 | 102 | |
|
103 | 103 | title = data[FORM_TITLE] |
|
104 | 104 | text = data[FORM_TEXT] |
|
105 |
|
|
|
105 | file = form.get_file() | |
|
106 | 106 | threads = data[FORM_THREADS] |
|
107 | 107 | |
|
108 | 108 | text = self._remove_invalid_links(text) |
|
109 | 109 | |
|
110 | 110 | post_thread = opening_post.get_thread() |
|
111 | 111 | |
|
112 |
post = Post.objects.create_post(title=title, text=text, |
|
|
112 | post = Post.objects.create_post(title=title, text=text, file=file, | |
|
113 | 113 | thread=post_thread, ip=ip, |
|
114 | 114 | opening_posts=threads) |
|
115 | 115 | post.notify_clients() |
General Comments 0
You need to be logged in to leave comments.
Login now