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