|
|
import re
|
|
|
import time
|
|
|
import pytz
|
|
|
|
|
|
from django import forms
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
|
from django.forms.util import ErrorList
|
|
|
from django.utils.translation import ugettext_lazy as _
|
|
|
import requests
|
|
|
|
|
|
from boards.mdx_neboard import formatters
|
|
|
from boards.models.post import TITLE_MAX_LENGTH
|
|
|
from boards.models import Tag, Post
|
|
|
from neboard import settings
|
|
|
import boards.settings as board_settings
|
|
|
|
|
|
|
|
|
CONTENT_TYPE_IMAGE = (
|
|
|
'image/jpeg',
|
|
|
'image/png',
|
|
|
'image/gif',
|
|
|
'image/bmp',
|
|
|
)
|
|
|
|
|
|
REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
|
|
|
|
|
|
VETERAN_POSTING_DELAY = 5
|
|
|
|
|
|
ATTRIBUTE_PLACEHOLDER = 'placeholder'
|
|
|
ATTRIBUTE_ROWS = 'rows'
|
|
|
|
|
|
LAST_POST_TIME = 'last_post_time'
|
|
|
LAST_LOGIN_TIME = 'last_login_time'
|
|
|
TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
|
|
|
TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
|
|
|
|
|
|
LABEL_TITLE = _('Title')
|
|
|
LABEL_TEXT = _('Text')
|
|
|
LABEL_TAG = _('Tag')
|
|
|
LABEL_SEARCH = _('Search')
|
|
|
|
|
|
ERROR_SPEED = _('Please wait %s seconds before sending message')
|
|
|
|
|
|
TAG_MAX_LENGTH = 20
|
|
|
|
|
|
IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
|
|
|
|
|
|
HTTP_RESULT_OK = 200
|
|
|
|
|
|
TEXTAREA_ROWS = 4
|
|
|
|
|
|
|
|
|
def get_timezones():
|
|
|
timezones = []
|
|
|
for tz in pytz.common_timezones:
|
|
|
timezones.append((tz, tz),)
|
|
|
return timezones
|
|
|
|
|
|
|
|
|
class FormatPanel(forms.Textarea):
|
|
|
"""
|
|
|
Panel for text formatting. Consists of buttons to add different tags to the
|
|
|
form text area.
|
|
|
"""
|
|
|
|
|
|
def render(self, name, value, attrs=None):
|
|
|
output = '<div id="mark-panel">'
|
|
|
for formatter in formatters:
|
|
|
output += '<span class="mark_btn"' + \
|
|
|
' onClick="addMarkToMsg(\'' + formatter.format_left + \
|
|
|
'\', \'' + formatter.format_right + '\')">' + \
|
|
|
formatter.preview_left + formatter.name + \
|
|
|
formatter.preview_right + '</span>'
|
|
|
|
|
|
output += '</div>'
|
|
|
output += super(FormatPanel, self).render(name, value, attrs=None)
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
class PlainErrorList(ErrorList):
|
|
|
def __unicode__(self):
|
|
|
return self.as_text()
|
|
|
|
|
|
def as_text(self):
|
|
|
return ''.join(['(!) %s ' % e for e in self])
|
|
|
|
|
|
|
|
|
class NeboardForm(forms.Form):
|
|
|
"""
|
|
|
Form with neboard-specific formatting.
|
|
|
"""
|
|
|
|
|
|
def as_div(self):
|
|
|
"""
|
|
|
Returns this form rendered as HTML <as_div>s.
|
|
|
"""
|
|
|
|
|
|
return self._html_output(
|
|
|
# TODO Do not show hidden rows in the list here
|
|
|
normal_row='<div class="form-row">'
|
|
|
'<div class="form-label">'
|
|
|
'%(label)s'
|
|
|
'</div>'
|
|
|
'<div class="form-input">'
|
|
|
'%(field)s'
|
|
|
'</div>'
|
|
|
'</div>'
|
|
|
'<div class="form-row">'
|
|
|
'%(help_text)s'
|
|
|
'</div>',
|
|
|
error_row='<div class="form-row">'
|
|
|
'<div class="form-label"></div>'
|
|
|
'<div class="form-errors">%s</div>'
|
|
|
'</div>',
|
|
|
row_ender='</div>',
|
|
|
help_text_html='%s',
|
|
|
errors_on_separate_row=True)
|
|
|
|
|
|
def as_json_errors(self):
|
|
|
errors = []
|
|
|
|
|
|
for name, field in list(self.fields.items()):
|
|
|
if self[name].errors:
|
|
|
errors.append({
|
|
|
'field': name,
|
|
|
'errors': self[name].errors.as_text(),
|
|
|
})
|
|
|
|
|
|
return errors
|
|
|
|
|
|
|
|
|
class PostForm(NeboardForm):
|
|
|
|
|
|
title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
|
|
|
label=LABEL_TITLE)
|
|
|
text = forms.CharField(
|
|
|
widget=FormatPanel(attrs={
|
|
|
ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
|
|
|
ATTRIBUTE_ROWS: TEXTAREA_ROWS,
|
|
|
}),
|
|
|
required=False, label=LABEL_TEXT)
|
|
|
image = forms.ImageField(required=False, label=_('Image'),
|
|
|
widget=forms.ClearableFileInput(
|
|
|
attrs={'accept': 'image/*'}))
|
|
|
image_url = forms.CharField(required=False, label=_('Image URL'),
|
|
|
widget=forms.TextInput(
|
|
|
attrs={ATTRIBUTE_PLACEHOLDER:
|
|
|
'http://example.com/image.png'}))
|
|
|
|
|
|
# This field is for spam prevention only
|
|
|
email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
|
|
|
widget=forms.TextInput(attrs={
|
|
|
'class': 'form-email'}))
|
|
|
threads = forms.CharField(required=False, label=_('Additional threads'),
|
|
|
widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
|
|
|
'123 456 789'}))
|
|
|
|
|
|
session = None
|
|
|
need_to_ban = False
|
|
|
|
|
|
def clean_title(self):
|
|
|
title = self.cleaned_data['title']
|
|
|
if title:
|
|
|
if len(title) > TITLE_MAX_LENGTH:
|
|
|
raise forms.ValidationError(_('Title must have less than %s '
|
|
|
'characters') %
|
|
|
str(TITLE_MAX_LENGTH))
|
|
|
return title
|
|
|
|
|
|
def clean_text(self):
|
|
|
text = self.cleaned_data['text'].strip()
|
|
|
if text:
|
|
|
if len(text) > board_settings.MAX_TEXT_LENGTH:
|
|
|
raise forms.ValidationError(_('Text must have less than %s '
|
|
|
'characters') %
|
|
|
str(board_settings
|
|
|
.MAX_TEXT_LENGTH))
|
|
|
return text
|
|
|
|
|
|
def clean_image(self):
|
|
|
image = self.cleaned_data['image']
|
|
|
|
|
|
if image:
|
|
|
self.validate_image_size(image.size)
|
|
|
|
|
|
return image
|
|
|
|
|
|
def clean_image_url(self):
|
|
|
url = self.cleaned_data['image_url']
|
|
|
|
|
|
image = None
|
|
|
if url:
|
|
|
image = self._get_image_from_url(url)
|
|
|
|
|
|
if not image:
|
|
|
raise forms.ValidationError(_('Invalid URL'))
|
|
|
else:
|
|
|
self.validate_image_size(image.size)
|
|
|
|
|
|
return image
|
|
|
|
|
|
def clean_threads(self):
|
|
|
threads_str = self.cleaned_data['threads']
|
|
|
|
|
|
if len(threads_str) > 0:
|
|
|
threads_id_list = threads_str.split(' ')
|
|
|
|
|
|
threads = list()
|
|
|
|
|
|
for thread_id in threads_id_list:
|
|
|
try:
|
|
|
thread = Post.objects.get(id=int(thread_id))
|
|
|
if not thread.is_opening() or thread.get_thread().archived:
|
|
|
raise ObjectDoesNotExist()
|
|
|
threads.append(thread)
|
|
|
except (ObjectDoesNotExist, ValueError):
|
|
|
raise forms.ValidationError(_('Invalid additional thread list'))
|
|
|
|
|
|
return threads
|
|
|
|
|
|
def clean(self):
|
|
|
cleaned_data = super(PostForm, self).clean()
|
|
|
|
|
|
if cleaned_data['email']:
|
|
|
self.need_to_ban = True
|
|
|
raise forms.ValidationError('A human cannot enter a hidden field')
|
|
|
|
|
|
if not self.errors:
|
|
|
self._clean_text_image()
|
|
|
|
|
|
if not self.errors and self.session:
|
|
|
self._validate_posting_speed()
|
|
|
|
|
|
return cleaned_data
|
|
|
|
|
|
def get_image(self):
|
|
|
"""
|
|
|
Gets image from file or URL.
|
|
|
"""
|
|
|
|
|
|
image = self.cleaned_data['image']
|
|
|
return image if image else self.cleaned_data['image_url']
|
|
|
|
|
|
def _clean_text_image(self):
|
|
|
text = self.cleaned_data.get('text')
|
|
|
image = self.get_image()
|
|
|
|
|
|
if (not text) and (not image):
|
|
|
error_message = _('Either text or image must be entered.')
|
|
|
self._errors['text'] = self.error_class([error_message])
|
|
|
|
|
|
def _validate_posting_speed(self):
|
|
|
can_post = True
|
|
|
|
|
|
posting_delay = settings.POSTING_DELAY
|
|
|
|
|
|
if board_settings.LIMIT_POSTING_SPEED:
|
|
|
now = time.time()
|
|
|
|
|
|
current_delay = 0
|
|
|
need_delay = False
|
|
|
|
|
|
if not LAST_POST_TIME in self.session:
|
|
|
self.session[LAST_POST_TIME] = now
|
|
|
|
|
|
need_delay = True
|
|
|
else:
|
|
|
last_post_time = self.session.get(LAST_POST_TIME)
|
|
|
current_delay = int(now - last_post_time)
|
|
|
|
|
|
need_delay = current_delay < posting_delay
|
|
|
|
|
|
if need_delay:
|
|
|
error_message = ERROR_SPEED % str(posting_delay
|
|
|
- current_delay)
|
|
|
self._errors['text'] = self.error_class([error_message])
|
|
|
|
|
|
can_post = False
|
|
|
|
|
|
if can_post:
|
|
|
self.session[LAST_POST_TIME] = now
|
|
|
|
|
|
def validate_image_size(self, size: int):
|
|
|
if size > board_settings.MAX_IMAGE_SIZE:
|
|
|
raise forms.ValidationError(
|
|
|
_('Image must be less than %s bytes')
|
|
|
% str(board_settings.MAX_IMAGE_SIZE))
|
|
|
|
|
|
def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
|
|
|
"""
|
|
|
Gets an image file from URL.
|
|
|
"""
|
|
|
|
|
|
img_temp = None
|
|
|
|
|
|
try:
|
|
|
# Verify content headers
|
|
|
response_head = requests.head(url, verify=False)
|
|
|
content_type = response_head.headers['content-type'].split(';')[0]
|
|
|
if content_type in CONTENT_TYPE_IMAGE:
|
|
|
length_header = response_head.headers.get('content-length')
|
|
|
if length_header:
|
|
|
length = int(length_header)
|
|
|
self.validate_image_size(length)
|
|
|
# Get the actual content into memory
|
|
|
response = requests.get(url, verify=False, stream=True)
|
|
|
|
|
|
# Download image, stop if the size exceeds limit
|
|
|
size = 0
|
|
|
content = b''
|
|
|
for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
|
|
|
size += len(chunk)
|
|
|
self.validate_image_size(size)
|
|
|
content += chunk
|
|
|
|
|
|
if response.status_code == HTTP_RESULT_OK and content:
|
|
|
# Set a dummy file name that will be replaced
|
|
|
# anyway, just keep the valid extension
|
|
|
filename = 'image.' + content_type.split('/')[1]
|
|
|
img_temp = SimpleUploadedFile(filename, content,
|
|
|
content_type)
|
|
|
except Exception:
|
|
|
# Just return no image
|
|
|
pass
|
|
|
|
|
|
return img_temp
|
|
|
|
|
|
|
|
|
class ThreadForm(PostForm):
|
|
|
|
|
|
tags = forms.CharField(
|
|
|
widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
|
|
|
max_length=100, label=_('Tags'), required=True)
|
|
|
|
|
|
def clean_tags(self):
|
|
|
tags = self.cleaned_data['tags'].strip()
|
|
|
|
|
|
if not tags or not REGEX_TAGS.match(tags):
|
|
|
raise forms.ValidationError(
|
|
|
_('Inappropriate characters in tags.'))
|
|
|
|
|
|
required_tag_exists = False
|
|
|
for tag in tags.split():
|
|
|
try:
|
|
|
Tag.objects.get(name=tag.strip().lower(), required=True)
|
|
|
required_tag_exists = True
|
|
|
break
|
|
|
except ObjectDoesNotExist:
|
|
|
pass
|
|
|
|
|
|
if not required_tag_exists:
|
|
|
all_tags = Tag.objects.filter(required=True)
|
|
|
raise forms.ValidationError(
|
|
|
_('Need at least one of the tags: ')
|
|
|
+ ', '.join([tag.name for tag in all_tags]))
|
|
|
|
|
|
return tags
|
|
|
|
|
|
def clean(self):
|
|
|
cleaned_data = super(ThreadForm, self).clean()
|
|
|
|
|
|
return cleaned_data
|
|
|
|
|
|
|
|
|
class SettingsForm(NeboardForm):
|
|
|
|
|
|
theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
|
|
|
image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
|
|
|
username = forms.CharField(label=_('User name'), required=False)
|
|
|
timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
|
|
|
|
|
|
def clean_username(self):
|
|
|
username = self.cleaned_data['username']
|
|
|
|
|
|
if username and not REGEX_TAGS.match(username):
|
|
|
raise forms.ValidationError(_('Inappropriate characters.'))
|
|
|
|
|
|
return username
|
|
|
|
|
|
|
|
|
class SearchForm(NeboardForm):
|
|
|
query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
|
|
|
|