Browse Source

upload attachment api, download atachment view

Rafał Pitoń 8 years ago
parent
commit
0e84a665e6

+ 20 - 3
docs/developers/settings.rst

@@ -165,6 +165,18 @@ forum_index_title
 Forum index title. Can be empty string if not set, in which case ``forum_name`` should be used instead.
 Forum index title. Can be empty string if not set, in which case ``forum_name`` should be used instead.
 
 
 
 
+MISAGO_403_IMAGE
+----------------
+
+Url (relative to STATIC_URL) to file that should be served if user has no permission to see requested attachment.
+
+
+MISAGO_404_IMAGE
+----------------
+
+Url (relative to STATIC_URL) to file that should be served if user has requested nonexistant attachment.
+
+
 MISAGO_ACL_EXTENSIONS
 MISAGO_ACL_EXTENSIONS
 ---------------------
 ---------------------
 
 
@@ -191,10 +203,15 @@ MISAGO_ADMIN_SESSION_EXPIRATION
 Maximum allowed lenght of inactivity period between two requests to admin namespaces. If its exceeded, user will be asked to sign in again to admin backed before being allowed to continue activities.
 Maximum allowed lenght of inactivity period between two requests to admin namespaces. If its exceeded, user will be asked to sign in again to admin backed before being allowed to continue activities.
 
 
 
 
-MISAGO_ATTACHMENTS_ROOT
------------------------
+MISAGO_ATTACHMENT_SECRET_LENGTH
+-------------------------------
+
+Length of attachment's secret (filenames and url token). The longer, the harder it is to bruteforce, but too long may conflict with your uploaded files storage limits (eg. filesystem path length limits).
+
+.. warning:
+   In order for Misago to support clustered deployments or CDN's (like Amazon's S3), its unable to validate user's permission to see the attachment at its source. Instead it has to rely on exessively long and hard to guess urls to attachments and assumption that your users will not "leak" source urls to attachments further.
 
 
-Path to directory that Misago should use to store post attachments. This directory shouldn't be accessible from outside world.
+   Generaly, neither you nor your users should use forums to exchange files containing valuable data, but if you do, you should make sure to secure it additionaly via other means like password-protected archives or file encryption solutions.
 
 
 
 
 MISAGO_AVATAR_SERVER_PATH
 MISAGO_AVATAR_SERVER_PATH

+ 6 - 1
docs/setup_maintenance.rst

@@ -19,7 +19,7 @@ Before you start make sure your hosting provider grants you:
 - SSH access to the server
 - SSH access to the server
 - Python 2.7 or 3.5
 - Python 2.7 or 3.5
 - PostgreSQL >= 9.4
 - PostgreSQL >= 9.4
-- At least 64 megabytes of free memory for Misago's process
+- At least 128 megabytes of free memory for Misago's processes
 - HTTP server that supports WSGI applications
 - HTTP server that supports WSGI applications
 - Crontab
 - Crontab
 
 
@@ -76,3 +76,8 @@ Deployment is a process in which you get your site running and reachable by your
 
 
 Misago is de facto Django with extra features added. This means deployment of Misago should be largery same to deployment of other Django-based solutions. Django documentation `already covers <https://docs.djangoproject.com/en/1.6/howto/deployment/>`_ supported deployment methods, and while on dedicated and VPS options deployment method depends largery on your choice and employed software stack, shared servers may differ greatly by the way how Django should be deployed. If thats the case, make sure you consult your ISP documentation and/or ask its rep for details about supported deployment method.
 Misago is de facto Django with extra features added. This means deployment of Misago should be largery same to deployment of other Django-based solutions. Django documentation `already covers <https://docs.djangoproject.com/en/1.6/howto/deployment/>`_ supported deployment methods, and while on dedicated and VPS options deployment method depends largery on your choice and employed software stack, shared servers may differ greatly by the way how Django should be deployed. If thats the case, make sure you consult your ISP documentation and/or ask its rep for details about supported deployment method.
 
 
+
+Securing MEDIA_ROOT
+-------------------
+
+By default Misago uses the ``FileSystemStorage`` strategy that stores user-uploaded files in your site's ``media`` directory. You need to make sure that you have disabled indexing of this directory contents in your HTTP server's settings, or your user-uploaded files will be discoverable by 3rd party.

+ 36 - 18
misago/acl/migrations/0003_default_roles.py

@@ -18,7 +18,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # account perms
+            # account
             'misago.users.permissions.account': {
             'misago.users.permissions.account': {
                 'name_changes_allowed': 2,
                 'name_changes_allowed': 2,
                 'name_changes_expire': 180,
                 'name_changes_expire': 180,
@@ -27,7 +27,7 @@ def create_default_roles(apps, schema_editor):
                 'allow_signature_images': 0,
                 'allow_signature_images': 0,
             },
             },
 
 
-            # profiles perms
+            # profiles
             'misago.users.permissions.profiles': {
             'misago.users.permissions.profiles': {
                 'can_browse_users_list': 1,
                 'can_browse_users_list': 1,
                 'can_search_users': 1,
                 'can_search_users': 1,
@@ -39,7 +39,13 @@ def create_default_roles(apps, schema_editor):
                 'can_see_hidden_users': 0,
                 'can_see_hidden_users': 0,
             },
             },
 
 
-            # delete users perms
+            # attachments
+            'misago.threads.permissions.attachments': {
+                'max_attachment_size': 4 * 1024,
+                'can_download_other_users_attachments': True,
+            },
+
+            # delete users
             'misago.users.permissions.delete': {
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
@@ -51,7 +57,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # account perms
+            # account
             'misago.users.permissions.account': {
             'misago.users.permissions.account': {
                 'name_changes_allowed': 0,
                 'name_changes_allowed': 0,
                 'name_changes_expire': 0,
                 'name_changes_expire': 0,
@@ -60,7 +66,7 @@ def create_default_roles(apps, schema_editor):
                 'allow_signature_images': 0,
                 'allow_signature_images': 0,
             },
             },
 
 
-            # profiles perms
+            # profiles
             'misago.users.permissions.profiles': {
             'misago.users.permissions.profiles': {
                 'can_browse_users_list': 1,
                 'can_browse_users_list': 1,
                 'can_search_users': 1,
                 'can_search_users': 1,
@@ -70,7 +76,12 @@ def create_default_roles(apps, schema_editor):
                 'can_see_hidden_users': 0,
                 'can_see_hidden_users': 0,
             },
             },
 
 
-            # delete users perms
+            # attachments
+            'misago.threads.permissions.attachments': {
+                'can_download_other_users_attachments': True,
+            },
+
+            # delete users
             'misago.users.permissions.delete': {
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
@@ -82,7 +93,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # account perms
+            # account
             'misago.users.permissions.account': {
             'misago.users.permissions.account': {
                 'name_changes_allowed': 5,
                 'name_changes_allowed': 5,
                 'name_changes_expire': 14,
                 'name_changes_expire': 14,
@@ -91,7 +102,7 @@ def create_default_roles(apps, schema_editor):
                 'allow_signature_images': 0,
                 'allow_signature_images': 0,
             },
             },
 
 
-            # profiles perms
+            # profiles
             'misago.users.permissions.profiles': {
             'misago.users.permissions.profiles': {
                 'can_browse_users_list': 1,
                 'can_browse_users_list': 1,
                 'can_search_users': 1,
                 'can_search_users': 1,
@@ -103,7 +114,7 @@ def create_default_roles(apps, schema_editor):
                 'can_see_hidden_users': 1,
                 'can_see_hidden_users': 1,
             },
             },
 
 
-            # warnings perms
+            # warnings
             'misago.users.permissions.warnings': {
             'misago.users.permissions.warnings': {
                 'can_see_other_users_warnings': 1,
                 'can_see_other_users_warnings': 1,
                 'can_warn_users': 1,
                 'can_warn_users': 1,
@@ -111,7 +122,14 @@ def create_default_roles(apps, schema_editor):
                 'can_be_warned': 0,
                 'can_be_warned': 0,
             },
             },
 
 
-            # moderation perms
+            # attachments
+            'misago.threads.permissions.attachments': {
+                'max_attachment_size': 8 * 1024,
+                'can_download_other_users_attachments': True,
+                'can_delete_other_users_attachments': True,
+            },
+
+            # moderation
             'misago.threads.permissions.threads': {
             'misago.threads.permissions.threads': {
                 'can_see_unapproved_content_lists': True,
                 'can_see_unapproved_content_lists': True,
                 'can_see_reported_content_lists': True,
                 'can_see_reported_content_lists': True,
@@ -123,7 +141,7 @@ def create_default_roles(apps, schema_editor):
                 'can_moderate_signatures': 1,
                 'can_moderate_signatures': 1,
             },
             },
 
 
-            # delete users perms
+            # delete users
             'misago.users.permissions.delete': {
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_newer_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
                 'can_delete_users_with_less_posts_than': 0,
@@ -135,7 +153,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # warnings perms
+            # warnings
             'misago.users.permissions.warnings': {
             'misago.users.permissions.warnings': {
                 'can_see_other_users_warnings': 1,
                 'can_see_other_users_warnings': 1,
             },
             },
@@ -146,7 +164,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # rename users perms
+            # rename users
             'misago.users.permissions.moderation': {
             'misago.users.permissions.moderation': {
                 'can_rename_users': 1,
                 'can_rename_users': 1,
             },
             },
@@ -157,7 +175,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # ban users perms
+            # ban users
             'misago.users.permissions.profiles': {
             'misago.users.permissions.profiles': {
                 'can_see_ban_details': 1,
                 'can_see_ban_details': 1,
             },
             },
@@ -175,7 +193,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # delete users perms
+            # delete users
             'misago.users.permissions.delete': {
             'misago.users.permissions.delete': {
                 'can_delete_users_newer_than': 3,
                 'can_delete_users_newer_than': 3,
                 'can_delete_users_with_less_posts_than': 7,
                 'can_delete_users_with_less_posts_than': 7,
@@ -187,7 +205,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # profiles perms
+            # profiles
             'misago.users.permissions.profiles': {
             'misago.users.permissions.profiles': {
                 'can_be_blocked': 0,
                 'can_be_blocked': 0,
             },
             },
@@ -198,7 +216,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # private threads perms
+            # private threads
             'misago.threads.permissions.privatethreads': {
             'misago.threads.permissions.privatethreads': {
                 'can_use_private_threads': 1,
                 'can_use_private_threads': 1,
                 'can_start_private_threads': 1,
                 'can_start_private_threads': 1,
@@ -214,7 +232,7 @@ def create_default_roles(apps, schema_editor):
     pickle_permissions(
     pickle_permissions(
         role,
         role,
         {
         {
-            # private threads perms
+            # private threads
             'misago.threads.permissions.privatethreads': {
             'misago.threads.permissions.privatethreads': {
                 'can_use_private_threads': 1,
                 'can_use_private_threads': 1,
                 'can_start_private_threads': 1,
                 'can_start_private_threads': 1,

+ 15 - 0
misago/conf/defaults.py

@@ -118,6 +118,7 @@ MISAGO_ACL_EXTENSIONS = (
     'misago.users.permissions.moderation',
     'misago.users.permissions.moderation',
     'misago.users.permissions.delete',
     'misago.users.permissions.delete',
     'misago.categories.permissions',
     'misago.categories.permissions',
+    'misago.threads.permissions.attachments',
     'misago.threads.permissions.threads',
     'misago.threads.permissions.threads',
     'misago.threads.permissions.privatethreads',
     'misago.threads.permissions.privatethreads',
 )
 )
@@ -272,6 +273,20 @@ MISAGO_POSTS_PER_PAGE = 15
 MISAGO_POSTS_TAIL = 7
 MISAGO_POSTS_TAIL = 7
 
 
 
 
+# Number of attachments possible to assign to single post
+MISAGO_POST_ATTACHMENTS_LIMIT = 16
+
+
+# Length of secret used for attachments url tokens and filenames
+MISAGO_ATTACHMENT_SECRET_LENGTH = 64
+
+
+# Names of files served when user requests file that doesn't exist or is unavailable
+# Those files will be sought within STATIC_ROOT directory
+MISAGO_404_IMAGE = 'misago/error-404.png'
+MISAGO_403_IMAGE = 'misago/error-403.png'
+
+
 # Controls max age in days of items that Misago has to process to make rankings
 # Controls max age in days of items that Misago has to process to make rankings
 # Used for active posters and most liked users lists
 # Used for active posters and most liked users lists
 # If your forum runs out of memory when trying to generate users rankings list
 # If your forum runs out of memory when trying to generate users rankings list

+ 0 - 3
misago/project_template/attachments/README.txt

@@ -1,3 +0,0 @@
-This directory is used by Misago to store uploaded posts attachments.
-
-Make sure its not accessible from outside!

+ 1 - 1
misago/project_template/static/README.txt

@@ -1,2 +1,2 @@
 This directory is used to gather publicly available assets like images, icons, css filers et all.
 This directory is used to gather publicly available assets like images, icons, css filers et all.
-Don't put any files here yourself, or you will risk losing them. Use themes/static instead.
+Don't put any files here yourself, as they may be deleted by "collectstatic" command. Use "theme/static" directory instead.

+ 109 - 0
misago/threads/api/attachments.py

@@ -0,0 +1,109 @@
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.template.defaultfilters import filesizeformat
+from django.utils.translation import gettext as _
+
+from rest_framework import viewsets
+from rest_framework.response import Response
+
+from misago.acl import add_acl
+
+from ..models import Attachment, AttachmentType
+from ..serializers import AttachmentSerializer
+
+
+IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
+
+
+class AttachmentViewSet(viewsets.ViewSet):
+    def create(self, request):
+        if not request.user.acl['max_attachment_size']:
+            raise PermissionDenied(_("You don't have permission to upload new files."))
+
+        try:
+            return self.create_attachment(request)
+        except ValidationError as e:
+            return Response({
+                'detail': e.args[0]
+            }, status=400)
+
+    def create_attachment(self, request):
+        upload = request.FILES.get('upload')
+        if not upload:
+            raise ValidationError(_("No file has been uploaded."))
+
+        user_roles = set(r.pk for r in request.user.get_roles())
+        filetype = validate_filetype(upload, user_roles)
+        validate_filesize(upload, filetype, request.user.acl['max_attachment_size'])
+
+        attachment = Attachment(
+            uuid=Attachment.generate_new_uuid(),
+            filetype=filetype,
+            uploader=request.user,
+            uploader_name=request.user.username,
+            uploader_slug=request.user.slug,
+            uploader_ip=request.user_ip,
+            filename=upload.name,
+        )
+
+        if is_upload_image(upload):
+            try:
+                attachment.set_image(upload)
+            except IOError:
+                raise ValidationError(_("Uploaded image was corrupted or invalid."))
+        else:
+            attachment.set_file(upload)
+
+        attachment.save()
+        add_acl(request.user, attachment)
+
+        return Response(AttachmentSerializer(attachment, context={'user': request.user}).data)
+
+
+def validate_filetype(upload, user_roles):
+    filename = upload.name.strip().lower()
+
+    queryset = AttachmentType.objects.filter(status=AttachmentType.ENABLED)
+    for filetype in queryset.prefetch_related('limit_uploaders_to'):
+        for extension in filetype.extensions_list:
+            if filename.endswith('.%s' % extension):
+                break
+        else:
+            continue
+
+        if filetype.mimetypes_list and upload.content_type not in filetype.mimetypes_list:
+            continue
+
+        if filetype.limit_uploaders_to.exists():
+            allowed_roles = set(r.pk for r in filetype.limit_uploaders_to.all())
+            if not user_roles & allowed_roles:
+                continue
+
+        return filetype
+
+    raise ValidationError(_("You can't upload files of this type."))
+
+
+def validate_filesize(upload, filetype, hard_limit):
+    if upload.size > hard_limit * 1024:
+        message = _("You can't upload files larger than %(limit)s. (Your file has %(upload)s)")
+        raise ValidationError(message % {
+            'upload': filesizeformat(upload.size).rstrip('.0'),
+            'limit': filesizeformat(hard_limit * 1024).rstrip('.0')
+        })
+
+    if filetype.size_limit and upload.size > filetype.size_limit * 1024:
+        message = _("You can't upload files of this type larger than %(limit)s. (Your file has %(upload)s)")
+        raise ValidationError(message % {
+            'upload': filesizeformat(upload.size).rstrip('.0'),
+            'limit': filesizeformat(filetype.size_limit * 1024).rstrip('.0')
+        })
+
+
+def is_upload_image(upload):
+    filename = upload.name.strip().lower()
+
+    for extension in IMAGE_EXTENSIONS:
+        if filename.endswith('.%s' % extension):
+            return True
+    return False
+

+ 1 - 1
misago/threads/api/postendpoints/merge.py

@@ -44,7 +44,7 @@ def posts_merge_endpoint(request, thread):
 
 
     add_acl(request.user, first_post)
     add_acl(request.user, first_post)
 
 
-    return Response(PostSerializer(first_post).data)
+    return Response(PostSerializer(first_post, context={'user': request.user}).data)
 
 
 
 
 def clean_posts_for_merge(request, thread):
 def clean_posts_for_merge(request, thread):

+ 2 - 3
misago/threads/api/threadposts.py

@@ -83,7 +83,6 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread_for_update(request, thread_pk).model
         thread = self.get_thread_for_update(request, thread_pk).model
         return posts_move_endpoint(request, thread, self.thread)
         return posts_move_endpoint(request, thread, self.thread)
 
 
-
     @list_route(methods=['post'])
     @list_route(methods=['post'])
     @transaction.atomic
     @transaction.atomic
     def split(self, request, thread_pk):
     def split(self, request, thread_pk):
@@ -117,7 +116,7 @@ class ViewSet(viewsets.ViewSet):
 
 
             make_users_status_aware(request.user, [post.poster])
             make_users_status_aware(request.user, [post.poster])
 
 
-            return Response(PostSerializer(post).data)
+            return Response(PostSerializer(post, context={'user': request.user}).data)
         else:
         else:
             return Response(posting.errors, status=400)
             return Response(posting.errors, status=400)
 
 
@@ -147,7 +146,7 @@ class ViewSet(viewsets.ViewSet):
             if post.poster:
             if post.poster:
                 make_users_status_aware(request.user, [post.poster])
                 make_users_status_aware(request.user, [post.poster])
 
 
-            return Response(PostSerializer(post).data)
+            return Response(PostSerializer(post, context={'user': request.user}).data)
         else:
         else:
             return Response(posting.errors, status=400)
             return Response(posting.errors, status=400)
 
 

+ 5 - 4
misago/threads/migrations/0001_initial.py

@@ -8,7 +8,6 @@ from django.contrib.postgres.fields import JSONField
 from django.db import migrations, models
 from django.db import migrations, models
 
 
 from misago.core.pgutils import CreatePartialCompositeIndex, CreatePartialIndex
 from misago.core.pgutils import CreatePartialCompositeIndex, CreatePartialIndex
-import misago.threads.models.attachment
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
@@ -225,13 +224,15 @@ class Migration(migrations.Migration):
             name='Attachment',
             name='Attachment',
             fields=[
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('uuid', models.CharField(db_index=True, max_length=64)),
+                ('uuid', models.CharField(max_length=64)),
                 ('uploaded_on', models.DateTimeField(default=django.utils.timezone.now)),
                 ('uploaded_on', models.DateTimeField(default=django.utils.timezone.now)),
                 ('uploader_name', models.CharField(max_length=255)),
                 ('uploader_name', models.CharField(max_length=255)),
                 ('uploader_slug', models.CharField(max_length=255)),
                 ('uploader_slug', models.CharField(max_length=255)),
                 ('uploader_ip', models.GenericIPAddressField()),
                 ('uploader_ip', models.GenericIPAddressField()),
                 ('filename', models.CharField(max_length=255)),
                 ('filename', models.CharField(max_length=255)),
-                ('file', models.FileField(upload_to=misago.threads.models.attachment.clean_upload_to)),
+                ('thumbnail', models.ImageField(blank=True, null=True, upload_to='attachments')),
+                ('image', models.ImageField(blank=True, null=True, upload_to='attachments')),
+                ('file', models.FileField(blank=True, null=True, upload_to='attachments')),
                 ('downloads', models.PositiveIntegerField(default=0)),
                 ('downloads', models.PositiveIntegerField(default=0)),
                 ('post', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='misago_threads.Post')),
                 ('post', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='misago_threads.Post')),
             ],
             ],
@@ -251,7 +252,7 @@ class Migration(migrations.Migration):
         ),
         ),
         migrations.AddField(
         migrations.AddField(
             model_name='attachment',
             model_name='attachment',
-            name='type',
+            name='filetype',
             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.AttachmentType'),
             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.AttachmentType'),
         ),
         ),
         migrations.AddField(
         migrations.AddField(

+ 61 - 8
misago/threads/models/attachment.py

@@ -1,16 +1,18 @@
+from io import BytesIO
+
 from django.conf import settings
 from django.conf import settings
+from django.core.files import File
+from django.core.files.base import ContentFile
+from django.core.urlresolvers import reverse
 from django.db import models
 from django.db import models
 from django.utils import timezone
 from django.utils import timezone
-
-
-def clean_upload_to(instance, filename):
-    instance.filename = filename
-
+from django.utils.crypto import get_random_string
+from PIL import Image
 
 
 
 
 class Attachment(models.Model):
 class Attachment(models.Model):
-    uuid = models.CharField(max_length=64, db_index=True)
-    type = models.ForeignKey('AttachmentType')
+    uuid = models.CharField(max_length=64)
+    filetype = models.ForeignKey('AttachmentType')
     post = models.ForeignKey(
     post = models.ForeignKey(
         'Post',
         'Post',
         blank=True,
         blank=True,
@@ -31,6 +33,57 @@ class Attachment(models.Model):
     uploader_ip = models.GenericIPAddressField()
     uploader_ip = models.GenericIPAddressField()
 
 
     filename = models.CharField(max_length=255)
     filename = models.CharField(max_length=255)
-    file = models.FileField(upload_to=clean_upload_to)
+
+    thumbnail = models.ImageField(blank=True, null=True, upload_to='attachments')
+    image = models.ImageField(blank=True, null=True, upload_to='attachments')
+    file = models.FileField(blank=True, null=True, upload_to='attachments')
 
 
     downloads = models.PositiveIntegerField(default=0)
     downloads = models.PositiveIntegerField(default=0)
+
+    @classmethod
+    def generate_new_uuid(cls):
+        return get_random_string(settings.MISAGO_ATTACHMENT_SECRET_LENGTH)
+
+    @property
+    def is_image(self):
+        return bool(self.image)
+
+    @property
+    def is_file(self):
+        return not self.is_image
+
+    def get_absolute_url(self):
+        return reverse('misago:attachment', kwargs={
+            'pk': self.pk,
+            'uuid': self.uuid,
+        })
+
+    def get_thumbnail_url(self):
+        if self.is_image:
+            return reverse('misago:attachment-thumbnail', kwargs={
+                'pk': self.pk,
+                'uuid': self.uuid,
+            })
+        else:
+            return None
+
+    def set_file(self, upload):
+        file_secret = get_random_string(settings.MISAGO_ATTACHMENT_SECRET_LENGTH)
+        self.file = File(upload, '.'.join([file_secret, self.filetype.extensions_list[0]]))
+
+    def set_image(self, upload):
+        fileformat = self.filetype.extensions_list[0]
+
+        image_secret = get_random_string(settings.MISAGO_ATTACHMENT_SECRET_LENGTH)
+        image_filename = '.'.join([image_secret, fileformat])
+        self.image = File(upload, image_filename)
+
+        thumbnail = Image.open(upload)
+        thumbnail.thumbnail((500, 500))
+
+        thumb_stream = BytesIO()
+        thumbnail.save(thumb_stream, fileformat)
+
+        thumb_secret = get_random_string(settings.MISAGO_ATTACHMENT_SECRET_LENGTH)
+        thumb_filename = '.'.join([thumb_secret, fileformat])
+        self.thumbnail = ContentFile(thumb_stream, thumb_filename)

+ 78 - 0
misago/threads/permissions/attachments.py

@@ -0,0 +1,78 @@
+from django.utils.translation import ugettext_lazy as _
+
+from misago.acl import algebra
+from misago.acl.models import Role
+from misago.core import forms
+
+from ..models import Attachment
+
+
+"""
+Admin Permissions Form
+"""
+class PermissionsForm(forms.Form):
+    legend = _("Attachments")
+
+    max_attachment_size = forms.IntegerField(
+        label=_("Max attached file size (in kb)"),
+        help_text=_("Enter 0 to disable attachments."),
+        initial=500,
+        min_value=0
+    )
+
+    can_download_other_users_attachments = forms.YesNoSwitch(label=_("Can download other users attachments"))
+    can_delete_other_users_attachments = forms.YesNoSwitch(label=_("Can delete other users attachments"))
+
+
+class AnonymousPermissionsForm(forms.Form):
+    legend = _("Attachments")
+
+    can_download_other_users_attachments = forms.YesNoSwitch(label=_("Can download attachments"))
+
+
+def change_permissions_form(role):
+    if isinstance(role, Role):
+        if role.special_role != 'anonymous':
+            return PermissionsForm
+        else:
+            return AnonymousPermissionsForm
+    else:
+        return None
+
+
+"""
+ACL Builder
+"""
+def build_acl(acl, roles, key_name):
+    new_acl = {
+        'max_attachment_size': 0,
+        'can_download_other_users_attachments': False,
+        'can_delete_other_users_attachments': False,
+    }
+    new_acl.update(acl)
+
+    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+        max_attachment_size=algebra.greater,
+        can_download_other_users_attachments=algebra.greater,
+        can_delete_other_users_attachments=algebra.greater
+    )
+
+
+"""
+ACL's for targets
+"""
+def add_acl_to_attachment(user, attachment):
+    if user.is_authenticated() and user.id == attachment.uploader_id:
+        attachment.acl.update({
+            'can_download': True,
+            'can_delete': True,
+        })
+    else:
+        attachment.acl.update({
+            'can_download': user.acl['can_download_other_users_attachments'],
+            'can_delete': user.is_authenticated() and user.acl['can_delete_other_users_attachments'],
+        })
+
+
+def register_with(registry):
+    registry.acl_annotator(Attachment, add_acl_to_attachment)

+ 0 - 15
misago/threads/permissions/threads.py

@@ -21,15 +21,6 @@ Admin Permissions Forms
 class RolePermissionsForm(forms.Form):
 class RolePermissionsForm(forms.Form):
     legend = _("Threads")
     legend = _("Threads")
 
 
-    can_download_other_users_attachments = forms.YesNoSwitch(label=_("Can download other users attachments"))
-    max_attachment_size = forms.IntegerField(
-        label=_("Max attached file size (in kb)"),
-        help_text=_("Enter 0 to disable attachments."),
-        initial=500,
-        min_value=0
-    )
-    can_delete_other_users_attachments = forms.YesNoSwitch(label=_("Can delete other users attachments"))
-
     can_see_unapproved_content_lists = forms.YesNoSwitch(
     can_see_unapproved_content_lists = forms.YesNoSwitch(
         label=_("Can see unapproved content list"),
         label=_("Can see unapproved content list"),
         help_text=_('Allows access to "unapproved" tab on threads lists for '
         help_text=_('Allows access to "unapproved" tab on threads lists for '
@@ -204,9 +195,6 @@ ACL Builder
 """
 """
 def build_acl(acl, roles, key_name):
 def build_acl(acl, roles, key_name):
     acl.update({
     acl.update({
-        'can_download_other_users_attachments': False,
-        'max_attachment_size': 0,
-        'can_delete_other_users_attachments': False,
         'can_see_unapproved_content_lists': False,
         'can_see_unapproved_content_lists': False,
         'can_see_reported_content_lists': False,
         'can_see_reported_content_lists': False,
         'can_omit_flood_protection': False,
         'can_omit_flood_protection': False,
@@ -215,9 +203,6 @@ def build_acl(acl, roles, key_name):
     })
     })
 
 
     acl = algebra.sum_acls(acl, roles=roles, key=key_name,
     acl = algebra.sum_acls(acl, roles=roles, key=key_name,
-        can_download_other_users_attachments=algebra.greater,
-        max_attachment_size=algebra.greater,
-        can_delete_other_users_attachments=algebra.greater,
         can_see_unapproved_content_lists=algebra.greater,
         can_see_unapproved_content_lists=algebra.greater,
         can_see_reported_content_lists=algebra.greater,
         can_see_reported_content_lists=algebra.greater,
         can_omit_flood_protection=algebra.greater
         can_omit_flood_protection=algebra.greater

+ 1 - 0
misago/threads/serializers/__init__.py

@@ -1,3 +1,4 @@
 from .moderation import *
 from .moderation import *
 from .thread import *
 from .thread import *
 from .post import *
 from .post import *
+from .attachment import *

+ 73 - 0
misago/threads/serializers/attachment.py

@@ -0,0 +1,73 @@
+from django.core.urlresolvers import reverse
+
+from rest_framework import serializers
+
+from misago.core.utils import format_plaintext_for_html
+
+from ..models import Attachment
+
+
+__all__ = ['AttachmentSerializer']
+
+
+class AttachmentSerializer(serializers.ModelSerializer):
+    post = serializers.PrimaryKeyRelatedField(read_only=True)
+
+    acl = serializers.SerializerMethodField()
+    is_image = serializers.SerializerMethodField()
+    filetype = serializers.SerializerMethodField()
+    uploader_ip = serializers.SerializerMethodField()
+
+    url = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Attachment
+        fields = (
+            'id',
+            'filetype',
+            'post',
+            'uploaded_on',
+            'uploader_name',
+            'uploader_ip',
+            'filename',
+            'downloads',
+
+            'acl',
+            'is_image',
+
+            'url',
+        )
+
+    def get_acl(self, obj):
+        try:
+            return obj.acl
+        except AttributeError:
+            return None
+
+    def get_is_image(self, obj):
+        return obj.is_image
+
+    def get_filetype(self, obj):
+        return obj.filetype.name
+
+    def get_uploader_ip(self, obj):
+        if self.context['user'].acl['can_see_users_ips']:
+            return obj.uploader_ip
+        else:
+            return None
+
+    def get_url(self, obj):
+        return {
+            'index': obj.get_absolute_url(),
+            'thumb': obj.get_thumbnail_url(),
+            'uploader': self.get_uploader_url(obj),
+        }
+
+    def get_uploader_url(self, obj):
+        if obj.uploader_id:
+            return reverse('misago:user', kwargs={
+                'slug': obj.uploader_slug,
+                'pk': obj.uploader_id,
+            })
+        else:
+            return None

+ 7 - 0
misago/threads/serializers/post.py

@@ -14,6 +14,7 @@ __all__ = [
 
 
 class PostSerializer(serializers.ModelSerializer):
 class PostSerializer(serializers.ModelSerializer):
     poster = UserSerializer(many=False, read_only=True)
     poster = UserSerializer(many=False, read_only=True)
+    poster_ip = serializers.SerializerMethodField()
     parsed = serializers.SerializerMethodField()
     parsed = serializers.SerializerMethodField()
     attachments_cache = serializers.SerializerMethodField()
     attachments_cache = serializers.SerializerMethodField()
     last_editor = serializers.PrimaryKeyRelatedField(read_only=True)
     last_editor = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -61,6 +62,12 @@ class PostSerializer(serializers.ModelSerializer):
             'url',
             'url',
         )
         )
 
 
+    def get_poster_ip(self, obj):
+        if self.context['user'].acl['can_see_users_ips']:
+            return obj.poster_ip
+        else:
+            return None
+
     def get_parsed(self, obj):
     def get_parsed(self, obj):
         if obj.is_valid and not obj.is_event and (not obj.is_hidden or obj.acl['can_see_hidden']):
         if obj.is_valid and not obj.is_event and (not obj.is_hidden or obj.acl['can_see_hidden']):
             return obj.parsed
             return obj.parsed

+ 228 - 0
misago/threads/tests/test_attachments_api.py

@@ -0,0 +1,228 @@
+import json
+import os
+
+from django.core.urlresolvers import reverse
+from django.utils.encoding import smart_str
+
+from misago.acl.models import Role
+from misago.acl.testutils import override_acl
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from ..models import Attachment, AttachmentType
+
+
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
+TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
+TEST_LARGEPNG_PATH = os.path.join(TESTFILES_DIR, 'large.png')
+TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
+TEST_CORRUPTEDIMG_PATH = os.path.join(TESTFILES_DIR, 'corrupted.gif')
+
+
+class AttachmentsApiTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(AttachmentsApiTestCase, self).setUp()
+
+        AttachmentType.objects.all().delete()
+
+        self.api_link = reverse('misago:api:attachment-list')
+
+    def override_acl(self, new_acl=None):
+        if new_acl:
+            acl = self.user.acl.copy()
+            acl.update(new_acl)
+            override_acl(self.user, acl)
+
+    def test_anonymous(self):
+        """user has to be authenticated to be able to upload files"""
+        self.logout_user()
+
+        response = self.client.post(self.api_link)
+        self.assertEqual(response.status_code, 403)
+
+    def test_no_permission(self):
+        """user needs permission to upload files"""
+        self.override_acl({
+            'max_attachment_size': 0
+        })
+
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "don't have permission to upload new files", status_code=403)
+
+    def test_no_file_uploaded(self):
+        """no file uploaded scenario is handled"""
+        response = self.client.post(self.api_link)
+        self.assertContains(response, "No file has been uploaded.", status_code=400)
+
+    def test_invalid_extension(self):
+        """uploaded file's extension is rejected as invalid"""
+        AttachmentType.objects.create(
+            name="Test extension",
+            extensions='jpg,jpeg',
+            mimetypes=None
+        )
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+
+    def test_invalid_mime(self):
+        """uploaded file's mimetype is rejected as invalid"""
+        AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='loremipsum'
+        )
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+
+    def test_no_perm_to_type(self):
+        """user needs permission to upload files of this type"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='application/pdf'
+        )
+
+        user_roles = (r.pk for r in self.user.get_roles())
+        attachment_type.limit_uploaders_to.set(Role.objects.exclude(id__in=user_roles))
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+
+    def test_type_is_locked(self):
+        """new uploads for this filetype are locked"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='application/pdf',
+            status=AttachmentType.LOCKED
+        )
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+
+    def test_type_is_disabled(self):
+        """new uploads for this filetype are disabled"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='application/pdf',
+            status=AttachmentType.DISABLED
+        )
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "You can't upload files of this type.", status_code=400)
+
+    def test_upload_too_big_for_type(self):
+        """too big uploads are rejected"""
+        AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='image/png',
+            size_limit=100
+        )
+
+        with open(TEST_LARGEPNG_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+
+        self.assertContains(response, "can't upload files of this type larger than", status_code=400)
+
+    def test_upload_too_big_for_user(self):
+        """too big uploads are rejected"""
+        self.override_acl({
+            'max_attachment_size': 100
+        })
+
+        AttachmentType.objects.create(
+            name="Test extension",
+            extensions='png',
+            mimetypes='image/png'
+        )
+
+        with open(TEST_LARGEPNG_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "can't upload files larger than", status_code=400)
+
+    def test_corrupted_image_upload(self):
+        """corrupted image upload is handled"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='gif'
+        )
+
+        with open(TEST_CORRUPTEDIMG_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertContains(response, "Uploaded image was corrupted or invalid.", status_code=400)
+
+    def test_document_upload(self):
+        """successful upload creates orphan attachment"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='pdf',
+            mimetypes='application/pdf'
+        )
+
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        attachment = Attachment.objects.get(id=response_json['id'])
+
+        self.assertEqual(attachment.filename, 'document.pdf')
+        self.assertIsNotNone(attachment.file)
+        self.assertTrue(not attachment.image)
+        self.assertTrue(not attachment.thumbnail)
+
+        self.assertIsNone(response_json['post'])
+        self.assertEqual(response_json['uploader_name'], self.user.username)
+        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
+
+    def test_image_upload(self):
+        """successful upload creates orphan attachment with thumbnail"""
+        attachment_type = AttachmentType.objects.create(
+            name="Test extension",
+            extensions='jpeg,jpg',
+            mimetypes='image/jpeg'
+        )
+
+        with open(TEST_SMALLJPG_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertEqual(response.status_code, 200)
+
+        response_json = json.loads(smart_str(response.content))
+        attachment = Attachment.objects.get(id=response_json['id'])
+
+        self.assertEqual(attachment.filename, 'small.jpg')
+        self.assertTrue(not attachment.file)
+        self.assertIsNotNone(attachment.image)
+        self.assertIsNotNone(attachment.thumbnail)
+
+        self.assertIsNone(response_json['post'])
+        self.assertEqual(response_json['uploader_name'], self.user.username)
+        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())

+ 1 - 1
misago/threads/tests/test_attachmenttypeadmin_views.py

@@ -171,7 +171,7 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
 
         test_type.attachment_set.create(
         test_type.attachment_set.create(
             uuid='loremipsum',
             uuid='loremipsum',
-            type=test_type,
+            filetype=test_type,
             uploader_name='Bob',
             uploader_name='Bob',
             uploader_slug='bob',
             uploader_slug='bob',
             uploader_ip='127.0.0.1',
             uploader_ip='127.0.0.1',

+ 225 - 0
misago/threads/tests/test_attachmentview.py

@@ -0,0 +1,225 @@
+import os
+
+from django.conf import settings
+from django.core.urlresolvers import reverse
+
+from misago.acl.models import Role
+from misago.acl.testutils import override_acl
+from misago.categories.models import Category
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from .. import testutils
+from ..models import Attachment, AttachmentType
+
+
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
+TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
+TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
+
+
+class AttachmentViewTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(AttachmentViewTestCase, self).setUp()
+
+        AttachmentType.objects.all().delete()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.post = testutils.post_thread(category=self.category).first_post
+
+        self.api_link = reverse('misago:api:attachment-list')
+
+        self.attachment_type_jpg = AttachmentType.objects.create(
+            name="JPG",
+            extensions='jpeg,jpg'
+        )
+        self.attachment_type_pdf = AttachmentType.objects.create(
+            name="PDF",
+            extensions='pdf'
+        )
+
+        self.override_acl()
+
+    def override_acl(self, allow_download=True):
+        acl = self.user.acl.copy()
+        acl.update({
+            'max_attachment_size': 1000,
+            'can_download_other_users_attachments': allow_download
+        })
+        override_acl(self.user, acl)
+
+    def upload_document(self, is_orphaned=False, by_other_user=False):
+        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertEqual(response.status_code, 200)
+
+        attachment = Attachment.objects.order_by('id').last()
+
+        if not is_orphaned:
+            attachment.post = self.post
+            attachment.save()
+        if by_other_user:
+            attachment.uploader = None
+            attachment.save()
+
+        self.override_acl()
+
+        return attachment
+
+    def upload_image(self):
+        with open(TEST_SMALLJPG_PATH, 'rb') as upload:
+            response = self.client.post(self.api_link, data={
+                'upload': upload
+            })
+        self.assertEqual(response.status_code, 200)
+
+        attachment = Attachment.objects.order_by('id').last()
+
+        self.override_acl()
+
+        return attachment
+
+    def assertIs404(self, response):
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['location'].endswith(settings.MISAGO_404_IMAGE))
+
+    def assertIs403(self, response):
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['location'].endswith(settings.MISAGO_403_IMAGE))
+
+    def assertSuccess(self, response):
+        self.assertEqual(response.status_code, 302)
+        self.assertFalse(response['location'].endswith(settings.MISAGO_404_IMAGE))
+        self.assertFalse(response['location'].endswith(settings.MISAGO_403_IMAGE))
+
+    def test_nonexistant_file(self):
+        """user tries to retrieve nonexistant file"""
+        response = self.client.get(reverse('misago:attachment', kwargs={
+            'pk': 123,
+            'uuid': 'qwertyuiop'
+        }))
+
+        self.assertIs404(response)
+
+    def test_invalid_uuid(self):
+        """user tries to retrieve existing file using invalid uuid"""
+        attachment = self.upload_document()
+
+        response = self.client.get(reverse('misago:attachment', kwargs={
+            'pk': attachment.pk,
+            'uuid': 'qwertyuiop'
+        }))
+
+        self.assertIs404(response)
+
+    def test_other_user_file_no_permission(self):
+        """user tries to retrieve other user's file without perm"""
+        attachment = self.upload_document(by_other_user=True)
+
+        self.override_acl(False)
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertIs403(response)
+
+    def test_other_user_orphaned_file(self):
+        """user tries to retrieve other user's orphaned file"""
+        attachment = self.upload_document(is_orphaned=True, by_other_user=True)
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertIs403(response)
+
+    def test_document_thumbnail(self):
+        """user tries to retrieve thumbnail from non-image attachment"""
+        attachment = self.upload_document()
+
+        response = self.client.get(reverse('misago:attachment-thumbnail', kwargs={
+            'pk': attachment.pk,
+            'uuid': attachment.uuid
+        }))
+        self.assertIs404(response)
+
+    def test_no_role(self):
+        """user tries to retrieve attachment without perm to its type"""
+        attachment = self.upload_document()
+
+        user_roles = (r.pk for r in self.user.get_roles())
+        self.attachment_type_pdf.limit_downloaders_to.set(Role.objects.exclude(id__in=user_roles))
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertIs403(response)
+
+    def test_type_disabled(self):
+        """user tries to retrieve attachment the type disabled downloads"""
+        attachment = self.upload_document()
+
+        self.attachment_type_pdf.status = AttachmentType.DISABLED
+        self.attachment_type_pdf.save()
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertIs403(response)
+
+    def test_locked_type(self):
+        """user retrieves own locked file"""
+        attachment = self.upload_document()
+
+        self.attachment_type_pdf.status = AttachmentType.LOCKED
+        self.attachment_type_pdf.save()
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_own_file(self):
+        """user retrieves own file"""
+        attachment = self.upload_document()
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_other_user_file(self):
+        """user retrieves other user's file with perm"""
+        attachment = self.upload_document(by_other_user=True)
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_other_user_orphaned_file_is_staff(self):
+        """user retrieves other user's orphaned file because he is staff"""
+        attachment = self.upload_document(is_orphaned=True, by_other_user=True)
+
+        self.user.is_staff = True
+        self.user.save()
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_orphaned_file_is_uploader(self):
+        """user retrieves orphaned file because he is its uploader"""
+        attachment = self.upload_document(is_orphaned=True)
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_has_role(self):
+        """user retrieves file he has roles to download"""
+        attachment = self.upload_document()
+
+        user_roles = self.user.get_roles()
+        self.attachment_type_pdf.limit_downloaders_to.set(user_roles)
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_image(self):
+        """user retrieves """
+        attachment = self.upload_image()
+
+        response = self.client.get(attachment.get_absolute_url())
+        self.assertSuccess(response)
+
+    def test_image_thumb(self):
+        """user retrieves image's thumbnail"""
+        attachment = self.upload_image()
+
+        response = self.client.get(attachment.get_thumbnail_url())
+        self.assertSuccess(response)

BIN
misago/threads/tests/testfiles/corrupted.gif


BIN
misago/threads/tests/testfiles/document.pdf


BIN
misago/threads/tests/testfiles/large.png


BIN
misago/threads/tests/testfiles/small.jpg


+ 7 - 0
misago/threads/urls/__init__.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf import settings
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from ..views.attachment import attachment_server
 from ..views.goto import ThreadGotoPostView, ThreadGotoLastView, ThreadGotoNewView, ThreadGotoUnapprovedView
 from ..views.goto import ThreadGotoPostView, ThreadGotoLastView, ThreadGotoNewView, ThreadGotoUnapprovedView
 from ..views.list import ForumThreads, CategoryThreads, PrivateThreads
 from ..views.list import ForumThreads, CategoryThreads, PrivateThreads
 from ..views.thread import Thread, PrivateThread
 from ..views.thread import Thread, PrivateThread
@@ -109,3 +110,9 @@ urlpatterns += goto_patterns(
     new=ThreadGotoNewView,
     new=ThreadGotoNewView,
     unapproved=ThreadGotoUnapprovedView
     unapproved=ThreadGotoUnapprovedView
 )
 )
+
+
+urlpatterns += [
+    url(r'^attachment/(?P<uuid>[-a-zA-Z0-9]+)-(?P<pk>\d+)/', attachment_server, name='attachment'),
+    url(r'^attachment/thumb/(?P<uuid>[-a-zA-Z0-9]+)-(?P<pk>\d+)/', attachment_server, name='attachment-thumbnail', kwargs={'thumbnail': True}),
+]

+ 2 - 0
misago/threads/urls/api.py

@@ -1,10 +1,12 @@
 from misago.core.apirouter import MisagoApiRouter
 from misago.core.apirouter import MisagoApiRouter
 
 
+from ..api.attachments import AttachmentViewSet
 from ..api.threadposts import ThreadPostsViewSet
 from ..api.threadposts import ThreadPostsViewSet
 from ..api.threads import ThreadViewSet
 from ..api.threads import ThreadViewSet
 
 
 
 
 router = MisagoApiRouter()
 router = MisagoApiRouter()
+router.register(r'attachments', AttachmentViewSet, base_name='attachment')
 router.register(r'threads', ThreadViewSet, base_name='thread')
 router.register(r'threads', ThreadViewSet, base_name='thread')
 router.register(r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post')
 router.register(r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post')
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 3 - 1
misago/threads/viewmodels/posts.py

@@ -31,6 +31,8 @@ class ViewModel(object):
         make_posts_read_aware(request.user, thread.model, posts)
         make_posts_read_aware(request.user, thread.model, posts)
         make_users_status_aware(request.user, posters)
         make_users_status_aware(request.user, posters)
 
 
+        self._user = request.user
+
         self.posts = posts
         self.posts = posts
         self.paginator = paginator
         self.paginator = paginator
 
 
@@ -45,7 +47,7 @@ class ViewModel(object):
 
 
     def get_frontend_context(self):
     def get_frontend_context(self):
         context = {
         context = {
-            'results': PostSerializer(self.posts, many=True).data
+            'results': PostSerializer(self.posts, many=True, context={'user': self._user}).data
         }
         }
 
 
         context.update(self.paginator)
         context.update(self.paginator)

+ 62 - 0
misago/threads/views/attachment.py

@@ -0,0 +1,62 @@
+import os
+
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from django.db.models import F
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+
+from ..models import Attachment, AttachmentType
+
+
+ATTACHMENT_404_URL = '/'.join((settings.STATIC_URL, settings.MISAGO_404_IMAGE))
+ATTACHMENT_403_URL = '/'.join((settings.STATIC_URL, settings.MISAGO_403_IMAGE))
+
+
+def attachment_server(request, pk, uuid, thumbnail=False):
+    try:
+        url = serve_file(request, pk, uuid, thumbnail)
+        return redirect(url)
+    except Http404:
+        return redirect(ATTACHMENT_404_URL)
+    except PermissionDenied:
+        return redirect(ATTACHMENT_403_URL)
+
+
+def serve_file(request, pk, uuid, thumbnail):
+    queryset = Attachment.objects.select_related('filetype')
+    attachment = get_object_or_404(queryset, pk=pk, uuid=uuid)
+
+    if not request.user.is_staff:
+        allow_file_download(request, attachment)
+
+    attachment.downloads = F('downloads') + 1
+    attachment.save(update_fields=['downloads'])
+
+    if attachment.is_image:
+        if thumbnail:
+            return attachment.thumbnail.url
+        else:
+            return attachment.image.url
+    else:
+        if thumbnail:
+            raise Http404()
+        else:
+            return attachment.file.url
+
+
+def allow_file_download(request, attachment):
+    is_authenticated = request.user.is_authenticated()
+
+    if not attachment.post_id or not request.user.acl['can_download_other_users_attachments']:
+        if not is_authenticated or request.user.id != attachment.uploader_id:
+            raise PermissionDenied()
+
+    allowed_roles = set(r.pk for r in attachment.filetype.limit_downloaders_to.all())
+    if allowed_roles:
+        user_roles = set(r.pk for r in request.user.get_roles())
+        if not user_roles & allowed_roles:
+            raise PermissionDenied()
+
+    if attachment.filetype.status == AttachmentType.DISABLED:
+        raise PermissionDenied()