Merge lp:~widelands-dev/widelands-website/pybb_attachments into lp:widelands-website

Proposed by kaputtnik
Status: Merged
Merged at revision: 549
Proposed branch: lp:~widelands-dev/widelands-website/pybb_attachments
Merge into: lp:widelands-website
Diff against target: 947 lines (+425/-111)
16 files modified
mainpage/settings.py (+46/-5)
mainpage/validators.py (+30/-0)
pip_requirements.txt (+1/-0)
pybb/admin.py (+9/-2)
pybb/forms.py (+20/-13)
pybb/models.py (+14/-11)
pybb/settings.py (+1/-0)
pybb/static/css/forum.css (+4/-1)
pybb/templates/pybb/base.html (+1/-1)
pybb/templates/pybb/delete_post.html (+7/-2)
pybb/templates/pybb/inlines/attachment.html (+11/-0)
pybb/templates/pybb/inlines/post.html (+5/-9)
pybb/templates/pybb/post_form.html (+39/-1)
pybb/util.py (+149/-1)
pybb/views.py (+46/-27)
wlmaps/forms.py (+42/-38)
To merge this branch: bzr merge lp:~widelands-dev/widelands-website/pybb_attachments
Reviewer Review Type Date Requested Status
GunChleoc Approve
kaputtnik (community) Needs Resubmitting
Review via email: mp+370342@code.launchpad.net

Commit message

Allow attachments in the forum

Description of the change

Made attachments python3 compatible: https://bazaar.launchpad.net/~widelands-dev/widelands-website/pybb_attachments/revision/545

Set allowed file size to 5 Mb (as we have for nginx)

Delete attachment when deleting a post: https://bazaar.launchpad.net/~widelands-dev/widelands-website/pybb_attachments/revision/548

Added template for showing attachments.

Style tweaks, example: https://bugs.launchpad.net/widelands-website/+bug/964452/+attachment/5277903/+files/attachments_1.png

Autoreload css for the users if this got merged, so the users don't have to hit CTRL+F5

I'll do some explanations in the wiki and announce this change. Probably with the option to remove it again if we get too many problematic attachments.

To post a comment you must log in.
552. By kaputtnik

removed wrong div tag

Revision history for this message
GunChleoc (gunchleoc) wrote :

Code LGTM. Do we have a restriction on file types? We won't want people to upload malicious JavaScript ans stuff lie that, so we should have a list of legal file extensions at least.

Revision history for this message
kaputtnik (franku) wrote :

Currently there is no restriction on file types. The problem is that a file may not contain what the extension says it should be. Everyone can rename 'image.js' into 'image.png'...

A list of allowed extension do not suffer. I'll try to implement some checks for validating a files type and probably an 'allowed extension list'.

What about restricting uploads to users who have written x posts prior? From my side this can be implemented.

Revision history for this message
GunChleoc (gunchleoc) wrote :

> Everyone can rename 'image.js' into 'image.png'...

That's true. It would still be an advantage though if the browser then doesn't execute it.

Doing a full validation too is definitely a better idea.

> What about restricting uploads to users who have written x posts prior?

Also a good idea

553. By kaputtnik

make attachments only available to users who had written MAX_HIDDEN_POST

554. By kaputtnik

added first file validation logic; render the post_form manually; give textarea more height

555. By kaputtnik

some reafctoring

556. By kaputtnik

added check for .wgf (widelands savegames); added more comments

557. By kaputtnik

merged trunk

558. By kaputtnik

use Image verify()

Revision history for this message
kaputtnik (franku) wrote :

I am a bit lost here...

Some checks are made now, but i am unsure if we need them all. What is implemented:

1. Link of Attachment:
If a user clicks on a link of an attachment, he will be ever prompted for a download (or to open the file with an associated program), even if it is a file which the browser may can display, e.g. an image (png, jpg) or text file. This is not optimal, but i think it is more secure.

2. Restricting upload permission
A user has only permission to upload files, if he has had written 5 posts prior. The number of 5 derives from MAX_HIDDEN_POST at the moment, but i think it's better to have an own value for this. What number do we want?

3. Allowed extensions:
Added a list:
ALLOWED_EXTENSIONS = ['wgf', 'jpg', 'jpeg', 'gif', 'png', 'ogg', 'lua', 'ods', 'zip', 'json', 'txt', 'csv']
I am unsure if this list will be ever complete. E.g. some may want to upload some other types of files (e.g. py, odg, doc, ...)

4. Extension handling:
- Files without extension aren't allowed (can be problematic e.g. with the campvis file)
- For 'wgf' files: Check if some base entries can be found (e.g. '/binary/' or '/minimap.png')
- For image files: Check if this is really an image using the Pillow library. This finds corrupted .png but not corrupted .jpg
Do we need a check for each allowed extension?

5. Comparing Mime-Types:
When uploading a file, a Mime-Type (content-type) is also submitted. The submitted mime-type is mostly derived from the extension of the file. E.g. if you rename a file from 'image.png' to 'image.txt' the OS (or browser) sends the Mime-type 'text/plain', which is obviously wrong. To prevent such mime-type mismatch, a check is made which compares the sent mime-type with the mime-type derived from python-magic. e.g. python magic will show for 'image.txt' the mime-type 'image/png'.

As said, i am a bit lost, not really knowing if we need all this checks, or if they are enough at all :-S Any opinions?

Revision history for this message
GunChleoc (gunchleoc) wrote :

2. I have no opinion on this at this time. Maybe 10 or 20? We can easily change the value if it becomes a problem.

> Files without extension aren't allowed (can be problematic e.g. with the campvis file)

campvis will be replaced by campaigns.conf for the next version, so don't worry about it - we can allow .conf

Other extensions that we're using for Widelands & that might be interesting:

.wai - AI
.wmf - Map
.wrpl - Replay - useless with the accompanying .wgf though.

> Do we need a check for each allowed extension?

Since attachments are marked for download anyway, I'd say that checking for those that are easily checked will do for now.

Revision history for this message
kaputtnik (franku) wrote :

> Other extensions that we're using for Widelands & that might be interesting:

> .wai - AI
Ok

> .wmf - Map

I forgot to mention: If the extension is wmf, an errormessage is shown: "This seems to be a widelands map file. Please upload it at our maps section."

> .wrpl - Replay - useless with the accompanying .wgf though.

I am planning to make a wikipage describing, beside other things, the usage of replays, e.g. they have to be zipped together before uploading and link that wikipage in the upload form. But i have to check for .wrpl.wgf.

Revision history for this message
kaputtnik (franku) wrote :

I have to stop working on this, other wise i fear to change many other things :-D

Anyway i think this works. wai files need some special treatment though.

Please test on alpha: https://alpha.widelands.org/

review: Needs Resubmitting
559. By kaputtnik

merged with latest changes

560. By kaputtnik

added a Reset File button

561. By kaputtnik

added clamav check; fixed a failure for zip files

Revision history for this message
kaputtnik (franku) wrote :

stonerl, can you install and configure clamav on the server, please?

The current branch implemented a check with clamdscan, which needs a clamav daemon. We can also stick with clamscan, which can be run on demand, but this much slow: A file check last 55 sec. on my machine, whereas clamdscan usually needs only 0,1 sec.

The current branch is not running on alpha, yet. Since i am afk for some some days, i'll shut down alpha now.

Revision history for this message
Toni Förster (stonerl) wrote :

Already installed. ;)

562. By kaputtnik

make virus scan a stand alone validor

563. By kaputtnik

cleanups

Revision history for this message
GunChleoc (gunchleoc) wrote :

1 tiny nit.

I also wonder whether we should disallow map files - it might come in handy if somebody wants some feedback before publishing to the maps section.

Revision history for this message
kaputtnik (franku) wrote :

> I also wonder whether we should disallow map files - it might come in handy if somebody wants some feedback before publishing to the maps section.

I just don't want to have map files in the forum. I 'fear' most (new) users will upload a map in the forum instead in the 'right' place. A Feedback can also be done in the map comments, or by linking a map in the forum and ask there for a feedback. Once there is a fix for bug 357914 'Allow submitters of maps to upload new versions' get implement, it should be fine to not allow maps in forum.

But of course we can remove that part of code and if we get in trouble we can add it again.

564. By kaputtnik

MiB instead of Mb

Revision history for this message
kaputtnik (franku) wrote :

@Toni: Can we have the package 'clamdscan' installed? For some reason it is a recommended package for clamd and we do not install recommended packages by default.

Or is clamd configured to have on-access-scanning enabled? Didn't saw that it is configured like that.

Revision history for this message
Toni Förster (stonerl) wrote :

How do you intend to access clamav? The current mode is via socket:

LocalSocket /var/run/clamav/clamd.ctl

If you'd like, I can add a local TCP daemon on port 3310.

Revision history for this message
Toni Förster (stonerl) wrote :

Stupid me. :-) I installed clamdscan.

565. By kaputtnik

added attachment to admin site of a post

Revision history for this message
kaputtnik (franku) wrote :

Thanks :-)

But the check for clamdscan doesn't work on the server... i know why, but i don't know the underlying cause. Will talk on IRC about that issue when i have some more time.

Just found that map uploading is broken with the changes in this branch. So the code for map uploading needs adapting too...

review: Needs Fixing
566. By kaputtnik

refactored uploading of maps to fit with the FileUploadHandler

567. By kaputtnik

use a setting to enable virus check

568. By kaputtnik

use an absolute path for clamdscan. This is needed on the server

Revision history for this message
kaputtnik (franku) wrote :

Finally i got it (hopefully)

Please check again on alpha... https://alpha.widelands.org/

Revision history for this message
kaputtnik (franku) :
review: Needs Resubmitting
Revision history for this message
GunChleoc (gunchleoc) wrote :

I have tested map upload and attaching to forum.

review: Approve
Revision history for this message
kaputtnik (franku) wrote :

Manx thanks :)

I wait only for janus to approve the changes regarding python3. He will look at it next weekend.

569. By kaputtnik

simplified attachment hash

Revision history for this message
kaputtnik (franku) wrote :

Finally this is productive now :-)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'mainpage/settings.py'
2--- mainpage/settings.py 2019-04-11 16:11:21 +0000
3+++ mainpage/settings.py 2019-09-02 20:01:34 +0000
4@@ -1,4 +1,5 @@
5-# Django settings for widelands project.
6+# Django default settings for widelands project.
7+# Overwrite these settings in local_settings.py!
8
9 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
10 import os
11@@ -162,7 +163,7 @@
12 DEFAULT_FROM_EMAIL = 'noreply@widelands.org'
13 ACCOUNT_ACTIVATION_DAYS = 2 # Days an activation token keeps active
14
15-# Franku: SHA1 Needed as compatibility for old passwords
16+# SHA1 Needed as compatibility for old passwords
17 # https://docs.djangoproject.com/en/1.11/releases/1.10/#removed-weak-password-hashers-from-the-default-password-hashers-setting
18 PASSWORD_HASHERS = [
19 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
20@@ -190,10 +191,50 @@
21 ######################
22 # Pybb Configuration #
23 ######################
24-# See also pybb defaults in pybb.settings.py
25-PYBB_ATTACHMENT_ENABLE = False # disable gzip middleware when enabling attachments
26+
27+PYBB_ATTACHMENT_ENABLE = True
28+
29+# To prevent sending errors from the webserver, keep
30+# this below the webserver settings
31+PYBB_ATTACHMENT_SIZE_LIMIT = 1024*1024*4
32 PYBB_DEFAULT_MARKUP = 'markdown'
33-INTERNAL_PERM='pybb.can_access_internal' # The permission string derived from pybb.models.category
34+INTERNAL_PERM = 'pybb.can_access_internal' # The permission string derived from pybb.models.category
35+
36+##################################
37+# Uploading files and validation #
38+##################################
39+
40+# Use only this handler to get real a file in /tmp
41+# Some validation checks needs a real file
42+FILE_UPLOAD_HANDLERS = [
43+ 'django.core.files.uploadhandler.TemporaryFileUploadHandler',
44+ ]
45+
46+ALLOWED_EXTENSIONS = [
47+ 'wgf', 'jpg', 'jpeg', 'gif', 'png', 'ogg', 'lua',
48+ 'ods', 'zip', 'json', 'txt', 'csv', 'wai',
49+ ]
50+
51+# Widelands Savegame should contain at least these entries
52+WGF_CONTENT_CHECK = ['/binary/', '/map/', '/minimap.png', '/preload',]
53+
54+# Do not check mime type for these extensions
55+SKIP_MIME_EXTENSIONS = ['wai',]
56+
57+ALLOWED_WAI_SECTIONS = [
58+ 'magic_numbers', 'neuron_values',
59+ 'neuron_functions', 'fneurons',
60+ ]
61+
62+# Allow attachments only after this amount of posts
63+ALLOW_ATTACHMENTS_AFTER = 5
64+
65+# Page describing uploads
66+ATTACHMENT_DESCR_PAGE = 'Attachments'
67+
68+# If clamav including clamdscan is installed and running
69+# set this to True
70+VIRUS_CHECK = False
71
72 ##############################################
73 # Link classification and other Markup stuff #
74
75=== added file 'mainpage/validators.py'
76--- mainpage/validators.py 1970-01-01 00:00:00 +0000
77+++ mainpage/validators.py 2019-09-02 20:01:34 +0000
78@@ -0,0 +1,30 @@
79+from django.core.exceptions import ValidationError
80+from django.conf import settings
81+
82+import shutil
83+import subprocess
84+
85+
86+def virus_scan(uploaded_file):
87+ '''Scan uploaded_file for viruses with clamav.'''
88+
89+ if settings.VIRUS_CHECK:
90+ try:
91+ tmp_file_path = uploaded_file.temporary_file_path()
92+ process_compl = subprocess.run(['/usr/bin/clamdscan',
93+ '--multiscan',
94+ '--fdpass',
95+ tmp_file_path])
96+ if process_compl.returncode == 1:
97+ raise ValidationError(
98+ 'This file seems to contain malicious code.'
99+ )
100+ if process_compl.returncode == 2:
101+ raise ValidationError(
102+ 'Some error occured during virus scannning...'
103+ )
104+ except FileNotFoundError:
105+ raise ValidationError(
106+ 'Please check the installation of clamav and make \
107+ sure clamdscan is working.'
108+ )
109
110=== modified file 'pip_requirements.txt'
111--- pip_requirements.txt 2019-07-05 06:57:46 +0000
112+++ pip_requirements.txt 2019-09-02 20:01:34 +0000
113@@ -19,6 +19,7 @@
114 numpy==1.16.4
115 Pillow==6.1.0
116 pydot==1.4.1
117+python-magic==0.4.15
118 Sphinx==2.1.2
119 Whoosh==2.7.4
120
121
122=== modified file 'pybb/admin.py'
123--- pybb/admin.py 2019-02-27 09:14:05 +0000
124+++ pybb/admin.py 2019-09-02 20:01:34 +0000
125@@ -1,7 +1,7 @@
126 # -*- coding: utf-8
127 from django.utils.translation import ugettext_lazy as _
128 from django.contrib import admin
129-from pybb.models import Category, Forum, Topic, Post, Read
130+from pybb.models import Category, Forum, Topic, Post, Read, Attachment
131
132
133 def delete_selected(modeladmin, request, queryset):
134@@ -75,6 +75,12 @@
135 )
136
137
138+class AttachmentInline(admin.StackedInline):
139+ model = Attachment
140+ extra = 0
141+ exclude = ('hash',)
142+
143+
144 class PostAdmin(admin.ModelAdmin):
145 list_display = ['summary', 'topic', 'user', 'created', 'hidden']
146 list_per_page = 20
147@@ -82,12 +88,13 @@
148 date_hierarchy = 'created'
149 search_fields = ['body', 'topic__name']
150 actions = [delete_selected, unhide_post]
151+ inlines = [AttachmentInline,]
152 fieldsets = (
153 (None, {
154 'fields': ('topic', 'user', 'markup', 'hidden')
155 }
156 ),
157- (_('Additional options'), {
158+ (_('Date and Time'), {
159 'classes': ('collapse',),
160 'fields': (('created', 'updated'),)
161 }
162
163=== modified file 'pybb/forms.py'
164--- pybb/forms.py 2019-03-20 21:32:32 +0000
165+++ pybb/forms.py 2019-09-02 20:01:34 +0000
166@@ -1,6 +1,6 @@
167 import re
168 from datetime import datetime
169-import os.path
170+import os
171
172 from django import forms
173 from django.conf import settings
174@@ -10,16 +10,24 @@
175 from pybb.models import Topic, Post, Attachment
176 from pybb import settings as pybb_settings
177 from django.conf import settings
178+from .util import validate_file
179+from mainpage.validators import virus_scan
180
181
182 class AddPostForm(forms.ModelForm):
183 name = forms.CharField(label=_('Subject'))
184- attachment = forms.FileField(label=_('Attachment'), required=False)
185+ attachment = forms.FileField(
186+ label=_('Attachment'),
187+ required=False,
188+ validators=[virus_scan, validate_file, ])
189
190 class Meta:
191 model = Post
192- # Listing fields again to get the the right order; See also the TODO
193+ # Listing fields again to get the the right order
194 fields = ['name', 'body', 'markup', 'attachment', ]
195+ widgets = {
196+ 'body': forms.Textarea(attrs={'cols': 80, 'rows': 15}),
197+ }
198
199 def __init__(self, *args, **kwargs):
200 self.user = kwargs.pop('user', None)
201@@ -31,17 +39,10 @@
202 self.fields['name'].widget = forms.HiddenInput()
203 self.fields['name'].required = False
204
205- if not pybb_settings.ATTACHMENT_ENABLE:
206+ if not pybb_settings.ATTACHMENT_ENABLE or self.user.wlprofile.post_count() < settings.ALLOW_ATTACHMENTS_AFTER:
207 self.fields['attachment'].widget = forms.HiddenInput()
208 self.fields['attachment'].required = False
209
210- def clean_attachment(self):
211- if self.cleaned_data['attachment']:
212- memfile = self.cleaned_data['attachment']
213- if memfile.size > pybb_settings.ATTACHMENT_SIZE_LIMIT:
214- raise forms.ValidationError(_('Attachment is too big'))
215- return self.cleaned_data['attachment']
216-
217 def save(self, *args, **kwargs):
218 if self.forum:
219 topic_is_new = True
220@@ -69,9 +70,15 @@
221 name=memfile.name, post=post)
222 dir = os.path.join(settings.MEDIA_ROOT,
223 pybb_settings.ATTACHMENT_UPLOAD_TO)
224- fname = '%d.0' % post.id
225+ if not os.path.exists(dir):
226+ os.makedirs(dir)
227+
228+ fname = '{}.0'.format(post.id)
229 path = os.path.join(dir, fname)
230- file(path, 'w').write(memfile.read())
231+
232+ with open(path, 'wb') as f:
233+ f.write(memfile.read())
234+
235 obj.path = fname
236 obj.save()
237
238
239=== modified file 'pybb/models.py'
240--- pybb/models.py 2019-04-11 15:20:33 +0000
241+++ pybb/models.py 2019-09-02 20:01:34 +0000
242@@ -18,7 +18,7 @@
243 from django.conf import settings
244 from notification.models import send
245 from check_input.models import SuspiciousInput
246-
247+import magic
248
249 try:
250 from notification import models as notification
251@@ -345,6 +345,11 @@
252 def delete(self, *args, **kwargs):
253 self_id = self.id
254 head_post_id = self.topic.posts.order_by('created')[0].id
255+
256+ if self.attachments.all():
257+ for attach in self.attachments.all():
258+ attach.delete()
259+
260 super(Post, self).delete(*args, **kwargs)
261
262 self.topic.save()
263@@ -398,7 +403,7 @@
264 super(Attachment, self).save(*args, **kwargs)
265 if not self.hash:
266 self.hash = hashlib.sha1(
267- str(self.id) + settings.SECRET_KEY).hexdigest()
268+ bytes(self.id) + settings.SECRET_KEY.encode('utf-8')).hexdigest()
269 super(Attachment, self).save(*args, **kwargs)
270
271 def __str__(self):
272@@ -407,19 +412,17 @@
273 def get_absolute_url(self):
274 return reverse('pybb_attachment', args=[self.hash])
275
276- def size_display(self):
277- size = self.size
278- if size < 1024:
279- return '%b' % size
280- elif size < 1024 * 1024:
281- return '%dKb' % int(size / 1024)
282- else:
283- return '%.2fMb' % (size / float(1024 * 1024))
284-
285 def get_absolute_path(self):
286 return os.path.join(settings.MEDIA_ROOT, pybb_settings.ATTACHMENT_UPLOAD_TO,
287 self.path)
288
289+ def delete(self, *args, **kwargs):
290+ try:
291+ os.remove(self.get_absolute_path())
292+ except FileNotFoundError:
293+ pass
294+ super(Attachment, self).delete(*args, **kwargs)
295+
296
297 if notification is not None:
298 signals.post_save.connect(notification.handle_observations, sender=Post)
299
300=== modified file 'pybb/settings.py'
301--- pybb/settings.py 2019-03-15 08:58:09 +0000
302+++ pybb/settings.py 2019-09-02 20:01:34 +0000
303@@ -16,6 +16,7 @@
304 DEFAULT_MARKUP = get('PYBB_DEFAULT_MARKUP', 'bbcode')
305 ATTACHMENT_UPLOAD_TO = get('PYBB_ATTACHMENT_UPLOAD_TO', 'pybb/attachments')
306 ATTACHMENT_SIZE_LIMIT = get('PYBB_ATTACHMENT_SIZE_LIMIT', 1024 * 1024)
307+PYBB_ATTACHMENT_ENABLE = get('PYBB_ATTACHMENT_ENABLE', False)
308 ATTACHMENT_ENABLE = get('PYBB_ATTACHMENT_ENABLE', True)
309 INTERNAL_PERM = get('INTERNAL_PERM', 'pybb.can_access_internal')
310 LAST_POSTS_DAYS = get('LAST_POSTS_DAYS', 30)
311
312=== modified file 'pybb/static/css/forum.css'
313--- pybb/static/css/forum.css 2019-02-28 16:09:16 +0000
314+++ pybb/static/css/forum.css 2019-09-02 20:01:34 +0000
315@@ -132,6 +132,10 @@
316 border: 1px solid #474444;
317 }
318
319+.attachment {
320+ display: inline-block
321+}
322+
323 /*****************/
324 /* Edit/add Post */
325 /* related */
326@@ -145,7 +149,6 @@
327 }
328
329 .post-form #id_body {
330- height: 150px;
331 width: 100%;
332 }
333
334
335=== modified file 'pybb/templates/pybb/base.html'
336--- pybb/templates/pybb/base.html 2019-01-24 18:03:54 +0000
337+++ pybb/templates/pybb/base.html 2019-09-02 20:01:34 +0000
338@@ -7,7 +7,7 @@
339 {% endblock %}
340
341 {% block extra_head %}
342-<link rel="stylesheet" type="text/css" media="all" href="{% static 'css/forum.css' %}" />
343+<link rel="stylesheet" type="text/css" media="all" href="{% static 'css/forum.css' %}?v1" />
344
345 <link rel="alternate" type="application/atom+xml" title="Latest Posts on all forums" href="{% url 'pybb_feed_posts' %}" />
346 <link rel="alternate" type="application/atom+xml" title="Latest Topics on all forums" href="{% url 'pybb_feed_topics' %}" />
347
348=== modified file 'pybb/templates/pybb/delete_post.html'
349--- pybb/templates/pybb/delete_post.html 2018-10-14 13:24:15 +0000
350+++ pybb/templates/pybb/delete_post.html 2019-09-02 20:01:34 +0000
351@@ -15,13 +15,18 @@
352 <div class="preview-box">
353 <div class="content post">
354 {{ post.body_html|safe }}
355+
356+ {% if post.attachments.all %}
357+ {% include 'pybb/inlines/attachment.html' %}
358+ {% endif %}
359 </div>
360+ <br />
361 </div>
362 <form method="post">
363- <div class="info_line">
364+ <p class="info_line">
365 <input type="submit" value="{% trans "Yes, I am sure." %}" />
366 <input type="button" value="{% trans "Cancel" %}" onclick="history.back();" />
367- </div>
368+ </p>
369 {% csrf_token %}
370 </form>
371 </div>
372
373=== added file 'pybb/templates/pybb/inlines/attachment.html'
374--- pybb/templates/pybb/inlines/attachment.html 1970-01-01 00:00:00 +0000
375+++ pybb/templates/pybb/inlines/attachment.html 2019-09-02 20:01:34 +0000
376@@ -0,0 +1,11 @@
377+{% load i18n %}
378+
379+{% for attach in post.attachments.all %}
380+ <div class="attachment">
381+ <hr />
382+ {% trans "Attachment" %}:
383+ <a href="{{ attach.get_absolute_url }}" download="{{attach.name }}" type="{{attach.content_type}}">{{ attach.name }}</a>
384+ ({{ attach.size|filesizeformat }})
385+ </div>
386+{% endfor %}
387+
388
389=== modified file 'pybb/templates/pybb/inlines/post.html'
390--- pybb/templates/pybb/inlines/post.html 2018-12-21 12:50:32 +0000
391+++ pybb/templates/pybb/inlines/post.html 2019-09-02 20:01:34 +0000
392@@ -29,19 +29,16 @@
393 <a id="post-{{ post.id }}" href="{{post.get_absolute_url}}" title="{% trans "Permalink" %}" class="posRight small permalink">&nbsp;</a>
394 <span class="small">Posted at: {{ post.created|custom_date:user}}</span>
395 <hr />
396- <div class="post">
397 {{ post.body_html|safe }}
398- </div>
399+
400+ {% if post.updated %}
401+ <div class="small">{% trans "Edited" %}: {{ post.updated|custom_date:user|title}}</div>
402+ {% endif %}
403
404 {% if post.attachment_cache %}
405- {% for attach in post.attachment_cache %}
406- {% trans "Attachment" %}: <a href="{{ attach.get_absolute_url }}">{{ attach.name }}</a> ({{ attach.size_display }})
407- {% endfor %}
408+ {% include 'pybb/inlines/attachment.html' %}
409 {% endif %}
410
411- {% if post.updated %}
412- <span class="small">{% trans "Edited" %}: {{ post.updated|custom_date:user|title}}</span>
413- {% endif %}
414 <hr />
415 {% if user.is_authenticated %}
416 {% ifequal user.wlprofile.show_signatures 1 %}
417@@ -54,7 +51,6 @@
418 {{ post.user.wlprofile.signature|urlize|linebreaks }}
419 {% endif %}
420 {% endif %}
421-
422 <a class="button posRight" href="#top">
423 <img src="{% static 'forum/img/top.png' %}" alt ="" class="middle" />
424 <span class="middle">{% trans "Top" %}</span>
425
426=== modified file 'pybb/templates/pybb/post_form.html'
427--- pybb/templates/pybb/post_form.html 2018-11-22 11:08:51 +0000
428+++ pybb/templates/pybb/post_form.html 2019-09-02 20:01:34 +0000
429@@ -33,6 +33,11 @@
430
431 });
432 });
433+ $(document).ready(function() {
434+ $('#btn-file-reset').on('click', function() {
435+ $('#id_attachment').val('');
436+ });
437+});
438 </script>
439 {% endblock %}
440
441@@ -52,7 +57,40 @@
442 </div>
443
444 <form class="post-form" action="{{ form_url }}" method="post" enctype="multipart/form-data">
445- {{ form.as_p }}
446+ {% if not form.name.is_hidden %}
447+ <p>
448+ <p class="errormessage">{{ form.name.errors.as_text }}</p>
449+ {{ form.name.label }}:
450+ {{ form.name }}
451+ </p>
452+ {% endif %}
453+ <p>
454+ <p class="errormessage">{{ form.body.errors.as_text }}</p>
455+ {{ form.body.label }}:
456+ {{ form.body }}
457+ </p>
458+ <p>
459+ <p class="errormessage">{{ form.markup.errors.as_text }}</p>
460+ {{ form.markup.label }}:
461+ {{ form.markup }}
462+ </p>
463+ {% if not form.attachment.is_hidden %}
464+ <p>
465+ <p>
466+ Please refer to the wiki page
467+ <a href="{{ site }}/wiki/{{ wikipage }}">{{ wikipage }}</a>
468+ for pointers.
469+ </p>
470+ <p class="errormessage">{{ form.attachment.errors.as_text }}</p>
471+ {{ form.attachment.label }}
472+ {{ form.attachment }}
473+ <button id="btn-file-reset" type="button">
474+ <img src="{% static 'img/delete.png' %}" alt ="" class="middle" />
475+ <span class="middle">{% trans "Reset" %}</span>
476+ </button>
477+ </p>
478+ {% endif %}
479+
480 {% csrf_token %}
481 <button type="submit">
482 <img src="{% static 'forum/img/send.png' %}" alt ="" class="middle" />
483
484=== modified file 'pybb/util.py'
485--- pybb/util.py 2019-04-11 15:20:33 +0000
486+++ pybb/util.py 2019-09-02 20:01:34 +0000
487@@ -3,6 +3,7 @@
488 import traceback
489 import json
490 import re
491+import subprocess
492
493 from bs4 import BeautifulSoup, NavigableString
494 from datetime import datetime
495@@ -14,7 +15,14 @@
496 from django import forms
497 from django.core.paginator import Paginator, EmptyPage, InvalidPage
498 from django.conf import settings
499+from django.core.exceptions import ValidationError
500 from pybb import settings as pybb_settings
501+import magic
502+import zipfile
503+import configparser
504+from PIL import Image
505+
506+
507
508
509 def render_to(template_path):
510@@ -31,7 +39,6 @@
511 output = func(request, *args, **kwargs)
512 if not isinstance(output, dict):
513 return output
514-
515
516 # TODO(Franku): 'MIME_TYPE' is never in output as i can see for now.
517 # But if, this should maybe 'content_type' instead
518@@ -173,3 +180,144 @@
519 text = text.replace('&quot;', '"')
520 text = text.replace('&#39;', '\'')
521 return text
522+
523+
524+def validate_file(attachment):
525+
526+ tmp_file_path = attachment.temporary_file_path()
527+
528+ # Helper functions
529+ def _split_mime(mime_type):
530+ main, sub = mime_type.split('/', maxsplit=1)
531+ return {'maintype': main, 'subtype': sub}
532+
533+ def _is_image():
534+ # Use PIL to determine if it is a valid image file
535+ # works not for corrupted jpg
536+ try:
537+ with Image.open(tmp_file_path) as im:
538+ im.verify()
539+ except:
540+ return False
541+ return True
542+
543+ def _is_zip():
544+ try:
545+ zip_obj = zipfile.ZipFile(tmp_file_path)
546+ except zipfile.BadZipfile:
547+ return None
548+ return zip_obj
549+
550+ def _zip_contains(zip_parts):
551+ # Check if each entry in zip_parts is inside the attachment
552+ zip_obj = _is_zip()
553+ if zip_obj:
554+ try:
555+ for obj in zip_parts:
556+ zip_obj.getinfo(obj)
557+ except KeyError:
558+ return False
559+ return True
560+
561+
562+ # Main part of file checks
563+ # File size
564+ if attachment.size > pybb_settings.ATTACHMENT_SIZE_LIMIT:
565+ raise ValidationError(
566+ 'Attachment is too big. We allow max %(size)s MiB',
567+ params = {
568+ 'size': pybb_settings.ATTACHMENT_SIZE_LIMIT/1024/1024,
569+ }
570+ )
571+
572+ # Checks by file extension
573+ splitted_fn = attachment.name.rsplit('.', maxsplit=2)
574+ if len(splitted_fn) == 1:
575+ raise ValidationError(
576+ 'We do not allow uploading files without an extension.'
577+ )
578+
579+ ext = splitted_fn[-1]
580+ if not ext in settings.ALLOWED_EXTENSIONS:
581+ raise ValidationError(
582+ 'This type of file is not allowed.'
583+ )
584+
585+ # Widelands map file
586+ if ext == 'wmf':
587+ raise ValidationError(
588+ 'This seems to be a widelands map file. Please upload \
589+ it at our maps section.'
590+ )
591+
592+ # Widelands savegame (*.wgf) and widelands replay (*.wrpl.wgf)
593+ # are not the same.
594+ if ext == 'wgf' and not splitted_fn[-2] == 'wrpl':
595+ if not _zip_contains(settings.WGF_CONTENT_CHECK):
596+ raise ValidationError(
597+ 'This is not a valid widelands savegame.'
598+ )
599+
600+ # Widelands replay
601+ if ext == 'wrpl':
602+ raise ValidationError(
603+ 'This file is part of a replay. Please zip it together with \
604+ the corresponding .wrpl.wgf file and upload again.'
605+ )
606+
607+ if ext == 'zip':
608+ if _is_zip() == None:
609+ raise ValidationError(
610+ 'This is not a valid zip file.'
611+ )
612+
613+ # Widelands AI configuration
614+ if ext == 'wai':
615+ wai = configparser.ConfigParser()
616+ try:
617+ wai.read(tmp_file_path)
618+ wai_sections = wai.sections()
619+ if len(settings.ALLOWED_WAI_SECTIONS) == len(wai_sections):
620+ for section in settings.ALLOWED_WAI_SECTIONS:
621+ if section not in wai_sections:
622+ raise
623+ else:
624+ raise
625+ except:
626+ raise ValidationError(
627+ 'This not a valid wai file.'
628+ )
629+
630+ # Checks by MimeType
631+ # Get MIME-Type from python-magic
632+ magic_mime = magic.from_file(tmp_file_path, mime=True)
633+ magic_mime = _split_mime(magic_mime)
634+ send_mime = _split_mime(attachment.content_type)
635+
636+ # Check for valid image file. Use te mime-type provided by python-magic,
637+ # because for a renamed image the wrong mime-type is send by the browser.
638+ if magic_mime['maintype'] == 'image':
639+ if not _is_image():
640+ raise ValidationError(
641+ 'This is not a valid image: %(file)s',
642+ params={'file': attachment.name}
643+ )
644+
645+ # Compare Mime type send by browser and Mime type from python-magic.
646+ # We only compare the main type (the first part) because the second
647+ # part may not be recoginzed correctly. E.g. for .lua the submitted
648+ # type is 'text/x-lua' but 'x-lua' is not official at all. See:
649+ # https://www.iana.org/assignments/media-types/media-types.xhtml
650+ # Unrecoginzed extension are always send with mime type
651+ # 'application/octet-stream'. Skip if we know them.
652+ if not ext in settings.SKIP_MIME_EXTENSIONS:
653+ if not magic_mime['maintype'] == send_mime['maintype']:
654+ raise ValidationError(
655+ 'The file %(file)s looks like %(send_mime)s, \
656+ but we think it is %(magic_mime)s',
657+ params={
658+ 'file': attachment.name,
659+ 'send_mime': send_mime['maintype'],
660+ 'magic_mime': magic_mime['maintype'],
661+ },
662+ )
663
664=== modified file 'pybb/views.py'
665--- pybb/views.py 2019-05-27 18:18:14 +0000
666+++ pybb/views.py 2019-09-02 20:01:34 +0000
667@@ -11,6 +11,7 @@
668 from django.shortcuts import redirect
669 from django.db.models import Q
670 from django.http import Http404
671+from django.conf import settings
672
673 from pybb.util import render_to, build_form, quote_text, ajax, urlize
674 from pybb.models import Category, Forum, Topic, Post, Attachment,\
675@@ -77,8 +78,12 @@
676
677
678 def show_topic_ctx(request, topic_id):
679+ """View of topic posts including a form to add a Post."""
680+
681+ context = {}
682 try:
683 topic = Topic.objects.select_related().get(pk=topic_id)
684+ context.update({'topic': topic})
685 except Topic.DoesNotExist:
686 raise Http404()
687
688@@ -92,25 +97,37 @@
689 topic.update_read(request.user)
690
691 last_post = topic.posts.order_by('-created')[0]
692+ context.update({'last_post': last_post})
693
694 initial = {}
695+ user_is_mod = False
696 if request.user.is_authenticated:
697 initial = {'markup': 'markdown'}
698- form = AddPostForm(topic=topic, initial=initial)
699-
700- user_is_mod = pybb_moderated_by(topic, request.user)
701- subscribed = (request.user.is_authenticated and
702- request.user in topic.subscribers.all())
703-
704- is_spam = False
705- if topic.is_hidden:
706- is_spam = topic.posts.first().is_spam()
707+
708+ form = AddPostForm(topic=topic,
709+ initial=initial,
710+ user=request.user,
711+ )
712+ context.update({'form': form})
713+
714+ user_is_mod = pybb_moderated_by(topic, request.user)
715+ context.update({'user_is_mod': user_is_mod})
716+
717+ subscribed = (request.user.is_authenticated and
718+ request.user in topic.subscribers.all())
719+ context.update({'subscribed': subscribed})
720+
721+ is_spam = False
722+ if topic.is_hidden:
723+ is_spam = topic.posts.first().is_spam()
724+ context.update({'is_spam': is_spam})
725
726 if user_is_mod:
727 posts = topic.posts.select_related()
728 else:
729 posts = topic.posts.exclude(hidden=True).select_related()
730-
731+ context.update({'posts': posts})
732+
733 # TODO: fetch profiles
734 # profiles = Profile.objects.filter(user__pk__in=
735 # set(x.user.id for x in page.object_list))
736@@ -119,23 +136,24 @@
737 # for post in page.object_list:
738 # post.user.pybb_profile = profiles[post.user.id]
739
740- load_related(posts, Attachment.objects.all(), 'post')
741-
742- return {'topic': topic,
743- 'last_post': last_post,
744- 'form': form,
745- 'user_is_mod': user_is_mod,
746- 'subscribed': subscribed,
747- 'posts': posts,
748- 'page_size': pybb_settings.TOPIC_PAGE_SIZE,
749- 'form_url': reverse('pybb_add_post', args=[topic.id]),
750- 'is_spam': is_spam,
751- }
752+ if pybb_settings.PYBB_ATTACHMENT_ENABLE:
753+ load_related(posts, Attachment.objects.all(), 'post')
754+
755+ context.update({
756+ 'page_size': pybb_settings.TOPIC_PAGE_SIZE,
757+ 'form_url': reverse('pybb_add_post', args=[topic.id]),
758+ 'wikipage': settings.ATTACHMENT_DESCR_PAGE,
759+ })
760+
761+ return context
762+
763 show_topic = render_to('pybb/topic.html')(show_topic_ctx)
764
765
766 @login_required
767 def add_post_ctx(request, forum_id, topic_id):
768+ """ Standalone view for adding posts."""
769+
770 forum = None
771 topic = None
772
773@@ -224,6 +242,7 @@
774 'topic': topic,
775 'forum': forum,
776 'form_url': form_url,
777+ 'wikipage': settings.ATTACHMENT_DESCR_PAGE,
778 }
779 add_post = render_to('pybb/add_post.html')(add_post_ctx)
780
781@@ -297,7 +316,7 @@
782 if not allowed:
783 return HttpResponseRedirect(post.get_absolute_url())
784
785- if 'POST' == request.method:
786+ if request.method == 'POST':
787 topic = post.topic
788 forum = post.topic.forum
789 post.delete()
790@@ -352,12 +371,12 @@
791 return HttpResponseRedirect(reverse('pybb_topic', args=[topic.id]))
792
793
794-@login_required
795 def show_attachment(request, hash):
796 attachment = get_object_or_404(Attachment, hash=hash)
797- file_obj = file(attachment.get_absolute_path())
798- return HttpResponse(file_obj, content_type=attachment.content_type)
799-
800+
801+ with open(attachment.get_absolute_path(), 'rb') as file_obj:
802+ return HttpResponse(file_obj, content_type=attachment.content_type)
803+ return HTTP404
804
805 @login_required
806 @ajax
807
808=== modified file 'wlmaps/forms.py'
809--- wlmaps/forms.py 2019-07-12 08:11:24 +0000
810+++ wlmaps/forms.py 2019-09-02 20:01:34 +0000
811@@ -6,30 +6,32 @@
812
813 from django import forms
814 from django.forms import ModelForm
815+from django.conf import settings
816 from django.core.files.storage import default_storage
817-from django.conf import settings
818
819 from wlmaps.models import Map
820 import os
821 import shutil
822+import tempfile
823
824
825 class UploadMapForm(ModelForm):
826 """
827+ All file operations are done in the temp folder.
828+
829 We have to handle here three different kind of files:
830- 1. The map which is uploaded
831- 2. The files created by 'wl_map_info'
832- a. The json file containing infos of the map
833- b. The image of the minimap (png)
834-
835- The filename of uploaded maps may contain bad characters which must be handled.
836-
837- If a map get deleted in the database, the underlying files (.wmf, .png) still exists. Uploading
838- a map with the same name does not overwrite the existing file(s), instead they get a
839- name in form of 'filename_<random_chars>.wmf(.png)'. This is importand for linking the correct
840- minimap.
841- The map file and the minimap (png) have different random characters in such a case, because the map
842- get saved twice: One time for the call to wl_map_info, and when the model is saved.
843+ 1. The map which is uploaded, stored as e.g '/tmp/tmp_xyz.upload'.
844+ Because 'wl_map_info' can't handle files with this extension and
845+ produces a minimap with the same name like the initial name, the
846+ uploade file will be copied to a file with the name of the
847+ uploaded file and extension, e.g. '/tmp/original_filename.wmf'
848+ 2. 'wl_map_info' produces two files which will be stored at the same
849+ location as the uploaded file and named like the map file:
850+ a. '/tmp/original_filename.wmf.json': Contains infos of the map
851+ b. '/tmp/original_filename.wmf.png': The image of the minimap (png).
852+
853+ Because we can't be sure the original filename is a valid filename, we
854+ may modify it to be a valid filename.
855 """
856
857 class Meta:
858@@ -39,44 +41,47 @@
859 def clean(self):
860 cleaned_data = super(UploadMapForm, self).clean()
861
862- mem_file_obj = cleaned_data.get('file')
863- if not mem_file_obj:
864+ file_obj = cleaned_data.get('file')
865+ if not file_obj:
866 # no clean file => abort
867 return cleaned_data
868
869- try:
870- # Try to make a safe filename
871- safe_name = default_storage.get_valid_name(mem_file_obj.name)
872- file_path = settings.MEDIA_ROOT + 'wlmaps/maps/' + safe_name
873- saved_file = default_storage.save(file_path, mem_file_obj)
874- except UnicodeEncodeError:
875- self._errors['file'] = self.error_class(
876- ['The filename contains characters which cannot be handled. Please rename and upload again.'])
877- del cleaned_data['file']
878- return cleaned_data
879+ # Make a save filename and copy the uploaded file
880+ saved_file = file_obj.temporary_file_path()
881+ safe_name = default_storage.get_valid_name(file_obj.name)
882+ tmpdir = tempfile.gettempdir()
883+ copied_file = shutil.copyfile(saved_file,
884+ os.path.join(tmpdir, safe_name)
885+ )
886
887 try:
888 # call map info tool to generate minimap and json info file
889+ # change working directory so that the datadir is found
890 old_cwd = os.getcwd()
891 os.chdir(settings.WIDELANDS_SVN_DIR)
892- check_call(['wl_map_info', saved_file])
893-
894- # Deleting the file because it will be saved again when
895- # the model is saved.
896- default_storage.delete(saved_file)
897+ check_call(['wl_map_info', copied_file])
898 os.chdir(old_cwd)
899 except CalledProcessError:
900 self._errors['file'] = self.error_class(
901 ['The map file could not be processed.'])
902 del cleaned_data['file']
903+ os.remove(copied_file)
904 return cleaned_data
905
906- mapinfo = json.load(open(saved_file + '.json'))
907+ mapinfo = json.load(open(copied_file + '.json'))
908
909 if Map.objects.filter(name=mapinfo['name']):
910 self._errors['file'] = self.error_class(
911 ['A map with the same name already exists.'])
912 del cleaned_data['file']
913+ # Delete the file copy and the generated files
914+ try:
915+ os.remove(copied_file)
916+ os.remove(copied_file + '.json')
917+ os.remove(copied_file + '.png')
918+ except os.FileNotFoundError:
919+ pass
920+
921 return cleaned_data
922
923 # Add information to the map
924@@ -90,19 +95,18 @@
925 self.instance.world_name = mapinfo['world_name']
926 self.instance.wl_version_after = mapinfo['needs_widelands_version_after']
927
928- # mapinfo["minimap"] is the absolute path containing the path where it
929- # is saved, extract the name
930+ # mapinfo["minimap"] is the absolute path to the image file
931+ # We move the file to the correct destination
932 minimap_name = mapinfo['minimap'].rpartition('/')[2]
933 minimap_upload_to = self.instance._meta.get_field('minimap').upload_to
934- # Set the destination relative to MEDIA_ROOT
935 minimap_path = os.path.join(minimap_upload_to, minimap_name)
936 self.instance.minimap = minimap_path
937- # Move the minimap file (.png) from wlmaps/maps to wlmaps/minimaps
938 shutil.move(mapinfo['minimap'], os.path.join(
939 settings.MEDIA_ROOT, minimap_path))
940
941- # the json file is no longer needed
942- default_storage.delete(saved_file + '.json')
943+ # Final cleanup
944+ os.remove(copied_file + '.json')
945+ os.remove(copied_file)
946
947 return cleaned_data
948

Subscribers

People subscribed via source and target branches