support deleting extensions #69
65
README.md
65
README.md
@ -61,53 +61,46 @@ log into http://extensions.local:8111/admin/ with `admin`/`admin`.
|
||||
|
||||
### Blender ID
|
||||
|
||||
All the configuration of this web app is done via environment variables
|
||||
(there's only one `settings.py` regardless of an environment).
|
||||
|
||||
Blender Extensions, as all other Blender web services, uses Blender ID.
|
||||
To configure OAuth login, first create a new OAuth2 application in Blender ID with the following settings:
|
||||
Blender Extensions can also receive Blender ID account modifications such as badge updates
|
||||
via a webhook.
|
||||
|
||||
* Redirect URIs: `http://extensions.local:8111/oauth/authorized`
|
||||
* Client type: "Confidential";
|
||||
* Authorization grant type: "Authorization code";
|
||||
* Name: "Blender Extensions Dev";
|
||||
For development, Blender ID's code contains a fixture with an OAuth app and a webhook
|
||||
that should work without any changes to default configuration.
|
||||
To load this fixture, go to your development Blender ID and run the following:
|
||||
|
||||
Then copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file:
|
||||
|
||||
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
|
||||
export BID_OAUTH_SECRET=<SECRET HERE>
|
||||
|
||||
Run the dev server using the following command:
|
||||
|
||||
source .env && ./manage.py runserver 8111
|
||||
|
||||
#### Webhook
|
||||
|
||||
Blender Extensions can receive account modifications such as badge updates via a webhook,
|
||||
which has to be configured in Blender ID admin separately from the OAuth app.
|
||||
|
||||
In Admin › Blender-ID API › Webhooks click `Add Webhook` and set the following:
|
||||
|
||||
* Name: "Blender Extensions Dev";
|
||||
* URL: `http://extensions.local:8111/webhooks/user-modified/`;
|
||||
* App: choose the app created in the previous step;
|
||||
|
||||
Then copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
|
||||
|
||||
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
|
||||
./manage.py loaddata blender_extensions_devserver
|
||||
|
||||
**N.B.**: the webhook view delegates the actual updating of the user profile
|
||||
to a background task, so in order to see the updates locally, start the processing of
|
||||
tasks using the following:
|
||||
|
||||
source .env && ./manage.py process_tasks
|
||||
./manage.py process_tasks
|
||||
|
||||
#### Blender ID and staging/production
|
||||
|
||||
The above steps use local development setup as example.
|
||||
For staging/production the steps are the same, the only differences being
|
||||
the names of the app and the webhook,
|
||||
and `http://extensions.local:8111` being replaced with the appropriate base URL.
|
||||
For staging/production, create an OAuth2 application in Blender ID using
|
||||
Admin › Blender-ID › OAuth2 applications -> Add:
|
||||
|
||||
* Redirect URIs: `https://staging.extensions.blender.org/oauth/authorized` (`https://extensions.blender.org` for production);
|
||||
* Client type: "Confidential";
|
||||
* Authorization grant type: "Authorization code";
|
||||
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production);
|
||||
|
||||
Copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file:
|
||||
|
||||
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
|
||||
export BID_OAUTH_SECRET=<SECRET HERE>
|
||||
|
||||
Create a webhook using Admin › Blender-ID API › Webhooks > Add:
|
||||
|
||||
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production)";
|
||||
* URL: `https://staging.extensions.blender.org/webhooks/user-modified/` (or `https://extensions.blender.org/webhooks/user-modified/` for production);
|
||||
* App: choose the app created in the previous step;
|
||||
|
||||
Copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
|
||||
|
||||
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
|
||||
|
||||
## Pre-commit hooks
|
||||
|
||||
|
21
abuse/tests/test_abuse.py
Normal file
21
abuse/tests/test_abuse.py
Normal file
@ -0,0 +1,21 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version
|
||||
from common.tests.factories.users import UserFactory
|
||||
|
||||
POST_DATA = {
|
||||
'message': 'test message',
|
||||
'reason': '127',
|
||||
'version': '',
|
||||
}
|
||||
|
||||
|
||||
class ReportTest(TestCase):
|
||||
def test_report_twice(self):
|
||||
version = create_approved_version()
|
||||
user = UserFactory()
|
||||
self.client.force_login(user)
|
||||
url = version.extension.get_report_url()
|
||||
_ = self.client.post(url, POST_DATA)
|
||||
response = self.client.get(url, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
@ -5,7 +5,7 @@ from django.http import Http404
|
||||
from django.views.generic import DetailView
|
||||
from django.views.generic.list import ListView
|
||||
from django.views.generic.edit import CreateView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from .forms import ReportForm
|
||||
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW
|
||||
@ -37,15 +37,20 @@ class ReportList(
|
||||
class ReportExtensionView(
|
||||
LoginRequiredMixin,
|
||||
extensions.views.mixins.ListedExtensionMixin,
|
||||
UserPassesTestMixin,
|
||||
CreateView,
|
||||
):
|
||||
model = AbuseReport
|
||||
form_class = ReportForm
|
||||
|
||||
def test_func(self) -> bool:
|
||||
# TODO: best to redirect to existing report or show a friendly message
|
||||
return not AbuseReport.exists(user_id=self.request.user.pk, extension_id=self.extension.id)
|
||||
def get(self, request, *args, **kwargs):
|
||||
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
|
||||
report = AbuseReport.objects.filter(
|
||||
reporter_id=self.request.user.pk, extension_id=extension.id
|
||||
).first()
|
||||
if report is not None:
|
||||
return redirect('abuse:view-report', pk=report.pk)
|
||||
else:
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Link newly created rating to latest version and current user."""
|
||||
|
@ -253,9 +253,13 @@ if SENTRY_DSN:
|
||||
BLENDER_ID = {
|
||||
# MUST end in a slash:
|
||||
'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'),
|
||||
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT'),
|
||||
'OAUTH_SECRET': os.environ.get('BID_OAUTH_SECRET'),
|
||||
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get('BID_WEBHOOK_USER_MODIFIED_SECRET'),
|
||||
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT', 'BLENDER-EXTENSIONS-DEV'),
|
||||
'OAUTH_SECRET': os.environ.get(
|
||||
'BID_OAUTH_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
|
||||
),
|
||||
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get(
|
||||
'BID_WEBHOOK_USER_MODIFIED_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
|
||||
),
|
||||
}
|
||||
|
||||
TAGGIT_CASE_INSENSITIVE = True
|
||||
@ -316,3 +320,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
|
||||
EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
|
||||
|
||||
# FIXME: this controls the initial widget rendered server-side, and server-side validation.
|
||||
# If this list changes, the "accept" attribute has to be also updated in appendImageUploadForm.
|
||||
ALLOWED_PREVIEW_MIMETYPES = ('image/jpg', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4')
|
||||
|
@ -1,9 +1,13 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import extensions.models
|
||||
import files.models
|
||||
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -33,11 +37,15 @@ EditPreviewFormSet = forms.inlineformset_factory(
|
||||
|
||||
|
||||
class AddPreviewFileForm(forms.ModelForm):
|
||||
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
|
||||
|
||||
class Meta:
|
||||
model = files.models.File
|
||||
fields = ('caption', 'source')
|
||||
widgets = {
|
||||
'source': forms.ClearableFileInput(attrs={'accept': 'image/*,video/*'}),
|
||||
'source': forms.ClearableFileInput(
|
||||
attrs={'accept': ','.join(settings.ALLOWED_PREVIEW_MIMETYPES)}
|
||||
),
|
||||
}
|
||||
|
||||
caption = forms.CharField(max_length=255, required=False)
|
||||
@ -48,6 +56,20 @@ class AddPreviewFileForm(forms.ModelForm):
|
||||
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_source(self, *args, **kwargs):
|
||||
# Here source is a file-like object
|
||||
source = self.cleaned_data['source']
|
||||
# Guess MIME-type based on extension first
|
||||
mimetype_from_ext = guess_mimetype_from_ext(source.name)
|
||||
if mimetype_from_ext not in settings.ALLOWED_PREVIEW_MIMETYPES:
|
||||
raise ValidationError(self.msg_unexpected_file_type, code='invalid')
|
||||
# Guess MIME-type based on file's content
|
||||
mimetype_from_bytes = guess_mimetype_from_content(source)
|
||||
if mimetype_from_bytes not in settings.ALLOWED_PREVIEW_MIMETYPES:
|
||||
raise ValidationError(self.msg_unexpected_file_type, code='invalid')
|
||||
# TODO: handle scenario when guessed MIME-types don't match
|
||||
return source
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save Preview from the cleaned form data."""
|
||||
# If file with this hash was already uploaded by the same user, return it
|
||||
|
@ -41,7 +41,7 @@ function appendImageUploadForm() {
|
||||
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview">
|
||||
</div>
|
||||
<div class="align-items-center d-flex justify-content-between">
|
||||
<input accept="image/*" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
||||
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
||||
<ul class="pt-0">
|
||||
<li>
|
||||
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form pl-2 pr-0"><i class="i-refresh"></i> Reset</button>
|
||||
|
BIN
extensions/tests/files/test_preview_image_0001.gif
Normal file
BIN
extensions/tests/files/test_preview_image_0001.gif
Normal file
Binary file not shown.
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
BIN
extensions/tests/files/test_preview_image_0001.webp
Normal file
BIN
extensions/tests/files/test_preview_image_0001.webp
Normal file
Binary file not shown.
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 154 B |
BIN
extensions/tests/files/test_preview_image_renamed_gif.png
Normal file
BIN
extensions/tests/files/test_preview_image_renamed_gif.png
Normal file
Binary file not shown.
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
@ -100,7 +100,7 @@ class UpdateTest(TestCase):
|
||||
'form-TOTAL_FORMS': ['2'],
|
||||
}
|
||||
file_name1 = 'test_preview_image_0001.png'
|
||||
file_name2 = 'test_preview_image_0002.png'
|
||||
file_name2 = 'test_preview_image_0001.webp'
|
||||
url = extension.get_manage_url()
|
||||
user = extension.authors.first()
|
||||
self.client.force_login(user)
|
||||
@ -134,15 +134,15 @@ class UpdateTest(TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
file2.original_hash,
|
||||
'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3',
|
||||
'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
|
||||
)
|
||||
self.assertEqual(
|
||||
file2.hash, 'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3'
|
||||
file2.hash, 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616'
|
||||
)
|
||||
self.assertEqual(file1.original_name, file_name1)
|
||||
self.assertEqual(file2.original_name, file_name2)
|
||||
self.assertEqual(file1.size_bytes, 1163)
|
||||
self.assertEqual(file2.size_bytes, 1693)
|
||||
self.assertEqual(file2.size_bytes, 154)
|
||||
self.assertTrue(
|
||||
file1.source.url.startswith(
|
||||
'/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||
@ -151,10 +151,10 @@ class UpdateTest(TestCase):
|
||||
self.assertTrue(file1.source.url.endswith('.png'))
|
||||
self.assertTrue(
|
||||
file2.source.url.startswith(
|
||||
'/media/images/f8/f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3',
|
||||
'/media/images/21/213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
|
||||
)
|
||||
)
|
||||
self.assertTrue(file2.source.url.endswith('.png'))
|
||||
self.assertTrue(file2.source.url.endswith('.webp'))
|
||||
for f in (file1, file2):
|
||||
self.assertEqual(f.user_id, user.pk)
|
||||
|
||||
@ -203,3 +203,80 @@ class UpdateTest(TestCase):
|
||||
response.context['add_preview_formset'].forms[0].errors,
|
||||
{'source': ['File with this Hash already exists.']},
|
||||
)
|
||||
|
||||
def test_post_upload_validation_error_unexpected_preview_format_gif(self):
|
||||
extension = create_approved_version().extension
|
||||
|
||||
data = {
|
||||
**POST_DATA,
|
||||
'form-TOTAL_FORMS': ['2'],
|
||||
}
|
||||
file_name1 = 'test_preview_image_0001.gif'
|
||||
file_name2 = 'test_preview_image_0001.png'
|
||||
url = extension.get_manage_url()
|
||||
user = extension.authors.first()
|
||||
self.client.force_login(user)
|
||||
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||
TEST_FILES_DIR / file_name2, 'rb'
|
||||
) as fp2:
|
||||
files = {
|
||||
'form-0-source': fp1,
|
||||
'form-1-source': fp2,
|
||||
}
|
||||
response = self.client.post(url, {**data, **files})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.context['add_preview_formset'].forms[0].errors,
|
||||
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||
)
|
||||
|
||||
def test_post_upload_validation_error_unexpected_preview_format_tif(self):
|
||||
extension = create_approved_version().extension
|
||||
|
||||
data = {
|
||||
**POST_DATA,
|
||||
'form-TOTAL_FORMS': ['2'],
|
||||
}
|
||||
file_name1 = 'test_preview_image_0001.png'
|
||||
file_name2 = 'test_preview_image_0001.tif'
|
||||
url = extension.get_manage_url()
|
||||
user = extension.authors.first()
|
||||
self.client.force_login(user)
|
||||
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||
TEST_FILES_DIR / file_name2, 'rb'
|
||||
) as fp2:
|
||||
files = {
|
||||
'form-0-source': fp1,
|
||||
'form-1-source': fp2,
|
||||
}
|
||||
response = self.client.post(url, {**data, **files})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.context['add_preview_formset'].forms[1].errors,
|
||||
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||
)
|
||||
|
||||
def test_post_upload_validation_error_unexpected_preview_format_renamed_gif(self):
|
||||
extension = create_approved_version().extension
|
||||
|
||||
data = {
|
||||
**POST_DATA,
|
||||
'form-TOTAL_FORMS': ['1'],
|
||||
}
|
||||
file_name1 = 'test_preview_image_renamed_gif.png'
|
||||
url = extension.get_manage_url()
|
||||
user = extension.authors.first()
|
||||
self.client.force_login(user)
|
||||
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
|
||||
files = {
|
||||
'form-0-source': fp1,
|
||||
}
|
||||
response = self.client.post(url, {**data, **files})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.context['add_preview_formset'].forms[0].errors,
|
||||
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||
)
|
||||
|
@ -1,14 +1,13 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
|
||||
from files.utils import get_sha256
|
||||
from files.utils import get_sha256, guess_mimetype_from_ext
|
||||
from constants.base import (
|
||||
FILE_STATUS_CHOICES,
|
||||
FILE_TYPE_CHOICES,
|
||||
@ -137,8 +136,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
|
||||
self.original_name = self.source.name
|
||||
|
||||
if not self.content_type:
|
||||
content_type, _ = mimetypes.guess_type(self.original_name)
|
||||
self.content_type = content_type
|
||||
self.content_type = guess_mimetype_from_ext(self.original_name)
|
||||
if self.content_type:
|
||||
if 'image' in self.content_type:
|
||||
self.type = self.TYPES.IMAGE
|
||||
|
@ -1,12 +1,14 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import logging
|
||||
import zipfile
|
||||
import mimetypes
|
||||
import os
|
||||
import toml
|
||||
import zipfile
|
||||
|
||||
from lxml import etree
|
||||
import magic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MODULE_DIR = Path(__file__).resolve().parent
|
||||
@ -81,3 +83,17 @@ def read_manifest_from_zip(archive_path):
|
||||
logger.error(f"Error extracting from archive: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def guess_mimetype_from_ext(file_name: str) -> str:
|
||||
"""Guess MIME-type from the extension of the given file name."""
|
||||
mimetype_from_ext, _ = mimetypes.guess_type(file_name)
|
||||
return mimetype_from_ext
|
||||
|
||||
|
||||
def guess_mimetype_from_content(file_obj) -> str:
|
||||
"""Guess MIME-type based on a portion of the given file's bytes."""
|
||||
mimetype_from_bytes = magic.from_buffer(file_obj.read(2048), mime=True)
|
||||
# This file might be read again by validation or other utilities
|
||||
file_obj.seek(0)
|
||||
return mimetype_from_bytes
|
||||
|
@ -38,6 +38,7 @@ mistune==2.0.4
|
||||
multidict==6.0.2
|
||||
oauthlib==3.2.0
|
||||
Pillow==9.2.0
|
||||
python-magic==0.4.27
|
||||
requests==2.28.1
|
||||
requests-oauthlib==1.3.1
|
||||
semantic-version==2.10.0
|
||||
|
30
setup.cfg
30
setup.cfg
@ -1,17 +1,27 @@
|
||||
[flake8]
|
||||
ignore=
|
||||
D100, #: Missing docstring in public module
|
||||
D101, #: Missing docstring in public class
|
||||
D102, #: Missing docstring in public method
|
||||
#: Missing docstring in public module
|
||||
D100,
|
||||
#: Missing docstring in public class
|
||||
D101,
|
||||
#: Missing docstring in public method
|
||||
D102,
|
||||
D103,
|
||||
D107, #: Missing docstring in __init__
|
||||
#: Missing docstring in __init__
|
||||
D107,
|
||||
D205,
|
||||
D105, #: Missing docstring in magic method
|
||||
D203, #: 1 blank line required before class docstring
|
||||
D213, #: We adhere to D212 instead
|
||||
D4, #: All section formatting, which seems to be impossible to comply
|
||||
W503, #: We adhere to W504 instead: line break should be before binary operator
|
||||
D106, #: No docstring in public nested class (e.g. Meta)
|
||||
#: Missing docstring in magic method
|
||||
D105,
|
||||
#: 1 blank line required before class docstring
|
||||
D203,
|
||||
#: We adhere to D212 instead
|
||||
D213,
|
||||
#: All section formatting, which seems to be impossible to comply
|
||||
D4,
|
||||
#: We adhere to W504 instead: line break should be before binary operator
|
||||
W503,
|
||||
#: No docstring in public nested class (e.g. Meta)
|
||||
D106,
|
||||
extend-ignore = E203
|
||||
max-line-length = 100
|
||||
per-file-ignores =
|
||||
|
Loading…
Reference in New Issue
Block a user