##// END OF EJS Templates
Get mimetype for file in the form and use it for both images and attachments
neko259 -
r1371:ca879451 default
parent child Browse files
Show More
@@ -1,376 +1,387 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4
5 5 import pytz
6 6 from django import forms
7 7 from django.core.files.uploadedfile import SimpleUploadedFile
8 8 from django.core.exceptions import ObjectDoesNotExist
9 9 from django.forms.util import ErrorList
10 10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
11 11
12 12 from boards.mdx_neboard import formatters
13 13 from boards.models.attachment.downloaders import Downloader
14 14 from boards.models.post import TITLE_MAX_LENGTH
15 15 from boards.models import Tag, Post
16 from boards.utils import validate_file_size
16 from boards.utils import validate_file_size, get_file_mimetype, \
17 FILE_EXTENSION_DELIMITER
17 18 from neboard import settings
18 19 import boards.settings as board_settings
19 20 import neboard
20 21
21 22 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
22 23
23 24 VETERAN_POSTING_DELAY = 5
24 25
25 26 ATTRIBUTE_PLACEHOLDER = 'placeholder'
26 27 ATTRIBUTE_ROWS = 'rows'
27 28
28 29 LAST_POST_TIME = 'last_post_time'
29 30 LAST_LOGIN_TIME = 'last_login_time'
30 31 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
31 32 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
32 33
33 34 LABEL_TITLE = _('Title')
34 35 LABEL_TEXT = _('Text')
35 36 LABEL_TAG = _('Tag')
36 37 LABEL_SEARCH = _('Search')
37 38
38 39 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
39 40 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
40 41
41 42 TAG_MAX_LENGTH = 20
42 43
43 44 TEXTAREA_ROWS = 4
44 45
45 46 TRIPCODE_DELIM = '#'
46 47
47 48
48 49 def get_timezones():
49 50 timezones = []
50 51 for tz in pytz.common_timezones:
51 52 timezones.append((tz, tz),)
52 53 return timezones
53 54
54 55
55 56 class FormatPanel(forms.Textarea):
56 57 """
57 58 Panel for text formatting. Consists of buttons to add different tags to the
58 59 form text area.
59 60 """
60 61
61 62 def render(self, name, value, attrs=None):
62 63 output = '<div id="mark-panel">'
63 64 for formatter in formatters:
64 65 output += '<span class="mark_btn"' + \
65 66 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
66 67 '\', \'' + formatter.format_right + '\')">' + \
67 68 formatter.preview_left + formatter.name + \
68 69 formatter.preview_right + '</span>'
69 70
70 71 output += '</div>'
71 72 output += super(FormatPanel, self).render(name, value, attrs=None)
72 73
73 74 return output
74 75
75 76
76 77 class PlainErrorList(ErrorList):
77 78 def __unicode__(self):
78 79 return self.as_text()
79 80
80 81 def as_text(self):
81 82 return ''.join(['(!) %s ' % e for e in self])
82 83
83 84
84 85 class NeboardForm(forms.Form):
85 86 """
86 87 Form with neboard-specific formatting.
87 88 """
88 89
89 90 def as_div(self):
90 91 """
91 92 Returns this form rendered as HTML <as_div>s.
92 93 """
93 94
94 95 return self._html_output(
95 96 # TODO Do not show hidden rows in the list here
96 97 normal_row='<div class="form-row">'
97 98 '<div class="form-label">'
98 99 '%(label)s'
99 100 '</div>'
100 101 '<div class="form-input">'
101 102 '%(field)s'
102 103 '</div>'
103 104 '</div>'
104 105 '<div class="form-row">'
105 106 '%(help_text)s'
106 107 '</div>',
107 108 error_row='<div class="form-row">'
108 109 '<div class="form-label"></div>'
109 110 '<div class="form-errors">%s</div>'
110 111 '</div>',
111 112 row_ender='</div>',
112 113 help_text_html='%s',
113 114 errors_on_separate_row=True)
114 115
115 116 def as_json_errors(self):
116 117 errors = []
117 118
118 119 for name, field in list(self.fields.items()):
119 120 if self[name].errors:
120 121 errors.append({
121 122 'field': name,
122 123 'errors': self[name].errors.as_text(),
123 124 })
124 125
125 126 return errors
126 127
127 128
128 129 class PostForm(NeboardForm):
129 130
130 131 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
131 132 label=LABEL_TITLE,
132 133 widget=forms.TextInput(
133 134 attrs={ATTRIBUTE_PLACEHOLDER:
134 135 'test#tripcode'}))
135 136 text = forms.CharField(
136 137 widget=FormatPanel(attrs={
137 138 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
138 139 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
139 140 }),
140 141 required=False, label=LABEL_TEXT)
141 142 file = forms.FileField(required=False, label=_('File'),
142 143 widget=forms.ClearableFileInput(
143 144 attrs={'accept': 'file/*'}))
144 145 file_url = forms.CharField(required=False, label=_('File URL'),
145 146 widget=forms.TextInput(
146 147 attrs={ATTRIBUTE_PLACEHOLDER:
147 148 'http://example.com/image.png'}))
148 149
149 150 # This field is for spam prevention only
150 151 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
151 152 widget=forms.TextInput(attrs={
152 153 'class': 'form-email'}))
153 154 threads = forms.CharField(required=False, label=_('Additional threads'),
154 155 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
155 156 '123 456 789'}))
156 157
157 158 session = None
158 159 need_to_ban = False
159 160
161 def _update_file_extension(self, file):
162 if file:
163 extension = get_file_mimetype(file)
164 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
165 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
166
167 file.name = new_filename
168
160 169 def clean_title(self):
161 170 title = self.cleaned_data['title']
162 171 if title:
163 172 if len(title) > TITLE_MAX_LENGTH:
164 173 raise forms.ValidationError(_('Title must have less than %s '
165 174 'characters') %
166 175 str(TITLE_MAX_LENGTH))
167 176 return title
168 177
169 178 def clean_text(self):
170 179 text = self.cleaned_data['text'].strip()
171 180 if text:
172 181 max_length = board_settings.get_int('Forms', 'MaxTextLength')
173 182 if len(text) > max_length:
174 183 raise forms.ValidationError(_('Text must have less than %s '
175 184 'characters') % str(max_length))
176 185 return text
177 186
178 187 def clean_file(self):
179 188 file = self.cleaned_data['file']
180 189
181 190 if file:
182 191 validate_file_size(file.size)
192 self._update_file_extension(file)
183 193
184 194 return file
185 195
186 196 def clean_file_url(self):
187 197 url = self.cleaned_data['file_url']
188 198
189 199 file = None
190 200 if url:
191 201 file = self._get_file_from_url(url)
192 202
193 203 if not file:
194 204 raise forms.ValidationError(_('Invalid URL'))
195 205 else:
196 206 validate_file_size(file.size)
207 self._update_file_extension(file)
197 208
198 209 return file
199 210
200 211 def clean_threads(self):
201 212 threads_str = self.cleaned_data['threads']
202 213
203 214 if len(threads_str) > 0:
204 215 threads_id_list = threads_str.split(' ')
205 216
206 217 threads = list()
207 218
208 219 for thread_id in threads_id_list:
209 220 try:
210 221 thread = Post.objects.get(id=int(thread_id))
211 222 if not thread.is_opening() or thread.get_thread().archived:
212 223 raise ObjectDoesNotExist()
213 224 threads.append(thread)
214 225 except (ObjectDoesNotExist, ValueError):
215 226 raise forms.ValidationError(_('Invalid additional thread list'))
216 227
217 228 return threads
218 229
219 230 def clean(self):
220 231 cleaned_data = super(PostForm, self).clean()
221 232
222 233 if cleaned_data['email']:
223 234 self.need_to_ban = True
224 235 raise forms.ValidationError('A human cannot enter a hidden field')
225 236
226 237 if not self.errors:
227 238 self._clean_text_file()
228 239
229 240 if not self.errors and self.session:
230 241 self._validate_posting_speed()
231 242
232 243 return cleaned_data
233 244
234 245 def get_file(self):
235 246 """
236 247 Gets file from form or URL.
237 248 """
238 249
239 250 file = self.cleaned_data['file']
240 251 return file or self.cleaned_data['file_url']
241 252
242 253 def get_tripcode(self):
243 254 title = self.cleaned_data['title']
244 255 if title is not None and TRIPCODE_DELIM in title:
245 256 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
246 257 tripcode = hashlib.md5(code.encode()).hexdigest()
247 258 else:
248 259 tripcode = ''
249 260 return tripcode
250 261
251 262 def get_title(self):
252 263 title = self.cleaned_data['title']
253 264 if title is not None and TRIPCODE_DELIM in title:
254 265 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
255 266 else:
256 267 return title
257 268
258 269 def _clean_text_file(self):
259 270 text = self.cleaned_data.get('text')
260 271 file = self.get_file()
261 272
262 273 if (not text) and (not file):
263 274 error_message = _('Either text or file must be entered.')
264 275 self._errors['text'] = self.error_class([error_message])
265 276
266 277 def _validate_posting_speed(self):
267 278 can_post = True
268 279
269 280 posting_delay = settings.POSTING_DELAY
270 281
271 282 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
272 283 now = time.time()
273 284
274 285 current_delay = 0
275 286
276 287 if LAST_POST_TIME not in self.session:
277 288 self.session[LAST_POST_TIME] = now
278 289
279 290 need_delay = True
280 291 else:
281 292 last_post_time = self.session.get(LAST_POST_TIME)
282 293 current_delay = int(now - last_post_time)
283 294
284 295 need_delay = current_delay < posting_delay
285 296
286 297 if need_delay:
287 298 delay = posting_delay - current_delay
288 299 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
289 300 delay) % {'delay': delay}
290 301 self._errors['text'] = self.error_class([error_message])
291 302
292 303 can_post = False
293 304
294 305 if can_post:
295 306 self.session[LAST_POST_TIME] = now
296 307
297 308 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
298 309 """
299 310 Gets an file file from URL.
300 311 """
301 312
302 313 img_temp = None
303 314
304 315 try:
305 316 for downloader in Downloader.__subclasses__():
306 317 if downloader.handles(url):
307 318 return downloader.download(url)
308 319 # If nobody of the specific downloaders handles this, use generic
309 320 # one
310 321 return Downloader.download(url)
311 322 except forms.ValidationError as e:
312 323 raise e
313 324 except Exception as e:
314 325 # Just return no file
315 326 pass
316 327
317 328
318 329 class ThreadForm(PostForm):
319 330
320 331 tags = forms.CharField(
321 332 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
322 333 max_length=100, label=_('Tags'), required=True)
323 334
324 335 def clean_tags(self):
325 336 tags = self.cleaned_data['tags'].strip()
326 337
327 338 if not tags or not REGEX_TAGS.match(tags):
328 339 raise forms.ValidationError(
329 340 _('Inappropriate characters in tags.'))
330 341
331 342 required_tag_exists = False
332 343 tag_set = set()
333 344 for tag_string in tags.split():
334 345 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
335 346 tag_set.add(tag)
336 347
337 348 # If this is a new tag, don't check for its parents because nobody
338 349 # added them yet
339 350 if not created:
340 351 tag_set |= set(tag.get_all_parents())
341 352
342 353 for tag in tag_set:
343 354 if tag.required:
344 355 required_tag_exists = True
345 356 break
346 357
347 358 if not required_tag_exists:
348 359 raise forms.ValidationError(
349 360 _('Need at least one section.'))
350 361
351 362 return tag_set
352 363
353 364 def clean(self):
354 365 cleaned_data = super(ThreadForm, self).clean()
355 366
356 367 return cleaned_data
357 368
358 369
359 370 class SettingsForm(NeboardForm):
360 371
361 372 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
362 373 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
363 374 username = forms.CharField(label=_('User name'), required=False)
364 375 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
365 376
366 377 def clean_username(self):
367 378 username = self.cleaned_data['username']
368 379
369 380 if username and not REGEX_TAGS.match(username):
370 381 raise forms.ValidationError(_('Inappropriate characters.'))
371 382
372 383 return username
373 384
374 385
375 386 class SearchForm(NeboardForm):
376 387 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,46 +1,40 b''
1 import magic
2
3 1 from django.db import models
4 2
5 3 from boards import utils
6 4 from boards.models.attachment.viewers import get_viewers, AbstractViewer
7 from boards.utils import get_upload_filename
8
9 FILES_DIRECTORY = 'files/'
10 FILE_EXTENSION_DELIMITER = '.'
5 from boards.utils import get_upload_filename, get_file_mimetype
11 6
12 7
13 8 class AttachmentManager(models.Manager):
14 9 def create_with_hash(self, file):
15 10 file_hash = utils.get_file_hash(file)
16 11 existing = self.filter(hash=file_hash)
17 12 if len(existing) > 0:
18 13 attachment = existing[0]
19 14 else:
20 file_type = magic.from_buffer(file.chunks().__next__(), mime=True)\
21 .decode().split('/')[-1]
22 attachment = Attachment.objects.create(
23 file=file, mimetype=file_type, hash=file_hash)
15 file_type = get_file_mimetype(file)
16 attachment = self.create(file=file, mimetype=file_type,
17 hash=file_hash)
24 18
25 19 return attachment
26 20
27 21
28 22 class Attachment(models.Model):
29 23 objects = AttachmentManager()
30 24
31 25 file = models.FileField(upload_to=get_upload_filename)
32 26 mimetype = models.CharField(max_length=50)
33 27 hash = models.CharField(max_length=36)
34 28
35 29 def get_view(self):
36 30 file_viewer = None
37 31 for viewer in get_viewers():
38 32 if viewer.supports(self.mimetype):
39 33 file_viewer = viewer
40 34 break
41 35 if file_viewer is None:
42 36 file_viewer = AbstractViewer
43 37
44 38 return file_viewer(self.file, self.mimetype).get_view()
45 39
46 40
@@ -1,100 +1,95 b''
1 import hashlib
2 import os
3 from random import random
4 import time
5
6 1 from django.db import models
7 2 from django.template.defaultfilters import filesizeformat
8 3
9 4 from boards import thumbs, utils
10 5 import boards
11 6 from boards.models.base import Viewable
12 7 from boards.utils import get_upload_filename
13 8
14 9 __author__ = 'neko259'
15 10
16 11
17 12 IMAGE_THUMB_SIZE = (200, 150)
18 13 HASH_LENGTH = 36
19 14
20 15 CSS_CLASS_IMAGE = 'image'
21 16 CSS_CLASS_THUMB = 'thumb'
22 17
23 18
24 19 class PostImageManager(models.Manager):
25 20 def create_with_hash(self, image):
26 21 image_hash = utils.get_file_hash(image)
27 22 existing = self.filter(hash=image_hash)
28 23 if len(existing) > 0:
29 24 post_image = existing[0]
30 25 else:
31 26 post_image = PostImage.objects.create(image=image)
32 27
33 28 return post_image
34 29
35 30 def get_random_images(self, count, include_archived=False, tags=None):
36 31 images = self.filter(post_images__thread__archived=include_archived)
37 32 if tags is not None:
38 33 images = images.filter(post_images__threads__tags__in=tags)
39 34 return images.order_by('?')[:count]
40 35
41 36
42 37 class PostImage(models.Model, Viewable):
43 38 objects = PostImageManager()
44 39
45 40 class Meta:
46 41 app_label = 'boards'
47 42 ordering = ('id',)
48 43
49 44 width = models.IntegerField(default=0)
50 45 height = models.IntegerField(default=0)
51 46
52 47 pre_width = models.IntegerField(default=0)
53 48 pre_height = models.IntegerField(default=0)
54 49
55 50 image = thumbs.ImageWithThumbsField(upload_to=get_upload_filename,
56 51 blank=True, sizes=(IMAGE_THUMB_SIZE,),
57 52 width_field='width',
58 53 height_field='height',
59 54 preview_width_field='pre_width',
60 55 preview_height_field='pre_height')
61 56 hash = models.CharField(max_length=HASH_LENGTH)
62 57
63 58 def save(self, *args, **kwargs):
64 59 """
65 60 Saves the model and computes the image hash for deduplication purposes.
66 61 """
67 62
68 63 if not self.pk and self.image:
69 64 self.hash = utils.get_file_hash(self.image)
70 65 super(PostImage, self).save(*args, **kwargs)
71 66
72 67 def __str__(self):
73 68 return self.image.url
74 69
75 70 def get_view(self):
76 71 metadata = '{}, {}'.format(self.image.name.split('.')[-1],
77 72 filesizeformat(self.image.size))
78 73 return '<div class="{}">' \
79 74 '<a class="{}" href="{full}">' \
80 75 '<img class="post-image-preview"' \
81 76 ' src="{}"' \
82 77 ' alt="{}"' \
83 78 ' width="{}"' \
84 79 ' height="{}"' \
85 80 ' data-width="{}"' \
86 81 ' data-height="{}" />' \
87 82 '</a>' \
88 83 '<div class="image-metadata">'\
89 84 '<a href="{full}" download>{image_meta}</a>'\
90 85 '</div>' \
91 86 '</div>'\
92 87 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB,
93 88 self.image.url_200x150,
94 89 str(self.hash), str(self.pre_width),
95 90 str(self.pre_height), str(self.width), str(self.height),
96 91 full=self.image.url, image_meta=metadata)
97 92
98 93 def get_random_associated_post(self):
99 94 posts = boards.models.Post.objects.filter(images__in=[self])
100 95 return posts.order_by('?').first()
@@ -1,142 +1,148 b''
1 1 """
2 2 This module contains helper functions and helper classes.
3 3 """
4 4 import hashlib
5 5 from random import random
6 6 import time
7 7 import hmac
8 8
9 9 from django.core.cache import cache
10 10 from django.db.models import Model
11 11 from django import forms
12 12 from django.utils import timezone
13 13 from django.utils.translation import ugettext_lazy as _
14 import magic
14 15 from portage import os
15 16
16 17 import boards
17 18 from boards.settings import get_bool
18 19 from neboard import settings
19 20
20 21 CACHE_KEY_DELIMITER = '_'
21 22 PERMISSION_MODERATE = 'moderation'
22 23
23 24 HTTP_FORWARDED = 'HTTP_X_FORWARDED_FOR'
24 25 META_REMOTE_ADDR = 'REMOTE_ADDR'
25 26
26 27 SETTING_MESSAGES = 'Messages'
27 28 SETTING_ANON_MODE = 'AnonymousMode'
28 29
29 30 ANON_IP = '127.0.0.1'
30 31
31 32 UPLOAD_DIRS ={
32 33 'PostImage': 'images/',
33 34 'Attachment': 'files/',
34 35 }
35 36 FILE_EXTENSION_DELIMITER = '.'
36 37
37 38
38 39 def is_anonymous_mode():
39 40 return get_bool(SETTING_MESSAGES, SETTING_ANON_MODE)
40 41
41 42
42 43 def get_client_ip(request):
43 44 if is_anonymous_mode():
44 45 ip = ANON_IP
45 46 else:
46 47 x_forwarded_for = request.META.get(HTTP_FORWARDED)
47 48 if x_forwarded_for:
48 49 ip = x_forwarded_for.split(',')[-1].strip()
49 50 else:
50 51 ip = request.META.get(META_REMOTE_ADDR)
51 52 return ip
52 53
53 54
54 55 # TODO The output format is not epoch because it includes microseconds
55 56 def datetime_to_epoch(datetime):
56 57 return int(time.mktime(timezone.localtime(
57 58 datetime,timezone.get_current_timezone()).timetuple())
58 59 * 1000000 + datetime.microsecond)
59 60
60 61
61 62 def get_websocket_token(user_id='', timestamp=''):
62 63 """
63 64 Create token to validate information provided by new connection.
64 65 """
65 66
66 67 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
67 68 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
68 69 sign.update(user_id.encode())
69 70 sign.update(timestamp.encode())
70 71 token = sign.hexdigest()
71 72
72 73 return token
73 74
74 75
75 76 def cached_result(key_method=None):
76 77 """
77 78 Caches method result in the Django's cache system, persisted by object name,
78 79 object name and model id if object is a Django model.
79 80 """
80 81 def _cached_result(function):
81 82 def inner_func(obj, *args, **kwargs):
82 83 # TODO Include method arguments to the cache key
83 84 cache_key_params = [obj.__class__.__name__, function.__name__]
84 85 if isinstance(obj, Model):
85 86 cache_key_params.append(str(obj.id))
86 87
87 88 if key_method is not None:
88 89 cache_key_params += [str(arg) for arg in key_method(obj)]
89 90
90 91 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
91 92
92 93 persisted_result = cache.get(cache_key)
93 94 if persisted_result is not None:
94 95 result = persisted_result
95 96 else:
96 97 result = function(obj, *args, **kwargs)
97 98 cache.set(cache_key, result)
98 99
99 100 return result
100 101
101 102 return inner_func
102 103 return _cached_result
103 104
104 105
105 106 def is_moderator(request):
106 107 try:
107 108 moderate = request.user.has_perm(PERMISSION_MODERATE)
108 109 except AttributeError:
109 110 moderate = False
110 111
111 112 return moderate
112 113
113 114
114 115 def get_file_hash(file) -> str:
115 116 md5 = hashlib.md5()
116 117 for chunk in file.chunks():
117 118 md5.update(chunk)
118 119 return md5.hexdigest()
119 120
120 121
121 122 def validate_file_size(size: int):
122 123 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
123 124 if size > max_size:
124 125 raise forms.ValidationError(
125 126 _('File must be less than %s bytes')
126 127 % str(max_size))
127 128
128 129
129 130 def get_upload_filename(model_instance, old_filename):
130 131 # TODO Use something other than random number in file name
131 132 if hasattr(model_instance, 'mimetype'):
132 133 extension = model_instance.mimetype
133 134 else:
134 135 extension = old_filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
135 136 new_name = '{}{}.{}'.format(
136 137 str(int(time.mktime(time.gmtime()))),
137 138 str(int(random() * 1000)),
138 139 extension)
139 140
140 141 directory = UPLOAD_DIRS[type(model_instance).__name__]
141 142
142 143 return os.path.join(directory, new_name)
144
145
146 def get_file_mimetype(file) -> str:
147 return magic.from_buffer(file.chunks().__next__(), mime=True) \
148 .decode().split('/')[-1]
General Comments 0
You need to be logged in to leave comments. Login now