support deleting extensions #69

Merged
Oleg-Komarov merged 8 commits from delete-extension into main 2024-04-05 19:11:05 +02:00
14 changed files with 219 additions and 68 deletions
Showing only changes of commit 8990a9064a - Show all commits

View File

@ -61,53 +61,46 @@ log into http://extensions.local:8111/admin/ with `admin`/`admin`.
### Blender ID ### 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. 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` For development, Blender ID's code contains a fixture with an OAuth app and a webhook
* Client type: "Confidential"; that should work without any changes to default configuration.
* Authorization grant type: "Authorization code"; To load this fixture, go to your development Blender ID and run the following:
* Name: "Blender Extensions Dev";
Then copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file: ./manage.py loaddata blender_extensions_devserver
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>
**N.B.**: the webhook view delegates the actual updating of the user profile **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 to a background task, so in order to see the updates locally, start the processing of
tasks using the following: tasks using the following:
source .env && ./manage.py process_tasks ./manage.py process_tasks
#### Blender ID and staging/production #### Blender ID and staging/production
The above steps use local development setup as example. For staging/production, create an OAuth2 application in Blender ID using
For staging/production the steps are the same, the only differences being Admin Blender-ID OAuth2 applications -> Add:
the names of the app and the webhook,
and `http://extensions.local:8111` being replaced with the appropriate base URL. * 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 ## Pre-commit hooks

21
abuse/tests/test_abuse.py Normal file
View 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)

View File

@ -5,7 +5,7 @@ from django.http import Http404
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView 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 .forms import ReportForm
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW
@ -37,15 +37,20 @@ class ReportList(
class ReportExtensionView( class ReportExtensionView(
LoginRequiredMixin, LoginRequiredMixin,
extensions.views.mixins.ListedExtensionMixin, extensions.views.mixins.ListedExtensionMixin,
UserPassesTestMixin,
CreateView, CreateView,
): ):
model = AbuseReport model = AbuseReport
form_class = ReportForm form_class = ReportForm
def test_func(self) -> bool: def get(self, request, *args, **kwargs):
# TODO: best to redirect to existing report or show a friendly message extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
return not AbuseReport.exists(user_id=self.request.user.pk, extension_id=self.extension.id) 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): def form_valid(self, form):
"""Link newly created rating to latest version and current user.""" """Link newly created rating to latest version and current user."""

View File

@ -253,9 +253,13 @@ if SENTRY_DSN:
BLENDER_ID = { BLENDER_ID = {
# MUST end in a slash: # MUST end in a slash:
'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'), 'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'),
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT'), 'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT', 'BLENDER-EXTENSIONS-DEV'),
'OAUTH_SECRET': os.environ.get('BID_OAUTH_SECRET'), 'OAUTH_SECRET': os.environ.get(
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get('BID_WEBHOOK_USER_MODIFIED_SECRET'), '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 TAGGIT_CASE_INSENSITIVE = True
@ -316,3 +320,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
EMAIL_PORT = os.getenv('EMAIL_PORT', '587') EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') 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')

View File

@ -1,9 +1,13 @@
import logging import logging
from django import forms 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 extensions.models
import files.models import files.models
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,11 +37,15 @@ EditPreviewFormSet = forms.inlineformset_factory(
class AddPreviewFileForm(forms.ModelForm): class AddPreviewFileForm(forms.ModelForm):
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
class Meta: class Meta:
model = files.models.File model = files.models.File
fields = ('caption', 'source') fields = ('caption', 'source')
widgets = { 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) 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'}) self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs) 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): def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data.""" """Save Preview from the cleaned form data."""
# If file with this hash was already uploaded by the same user, return it # If file with this hash was already uploaded by the same user, return it

View File

@ -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"> <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>
<div class="align-items-center d-flex justify-content-between"> <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"> <ul class="pt-0">
<li> <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> <button class="btn btn-link btn-sm js-btn-reset-img-upload-form pl-2 pr-0"><i class="i-refresh"></i> Reset</button>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

View File

@ -100,7 +100,7 @@ class UpdateTest(TestCase):
'form-TOTAL_FORMS': ['2'], 'form-TOTAL_FORMS': ['2'],
} }
file_name1 = 'test_preview_image_0001.png' 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() url = extension.get_manage_url()
user = extension.authors.first() user = extension.authors.first()
self.client.force_login(user) self.client.force_login(user)
@ -134,15 +134,15 @@ class UpdateTest(TestCase):
) )
self.assertEqual( self.assertEqual(
file2.original_hash, file2.original_hash,
'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3', 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
) )
self.assertEqual( self.assertEqual(
file2.hash, 'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3' file2.hash, 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616'
) )
self.assertEqual(file1.original_name, file_name1) self.assertEqual(file1.original_name, file_name1)
self.assertEqual(file2.original_name, file_name2) self.assertEqual(file2.original_name, file_name2)
self.assertEqual(file1.size_bytes, 1163) self.assertEqual(file1.size_bytes, 1163)
self.assertEqual(file2.size_bytes, 1693) self.assertEqual(file2.size_bytes, 154)
self.assertTrue( self.assertTrue(
file1.source.url.startswith( file1.source.url.startswith(
'/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', '/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
@ -151,10 +151,10 @@ class UpdateTest(TestCase):
self.assertTrue(file1.source.url.endswith('.png')) self.assertTrue(file1.source.url.endswith('.png'))
self.assertTrue( self.assertTrue(
file2.source.url.startswith( 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): for f in (file1, file2):
self.assertEqual(f.user_id, user.pk) self.assertEqual(f.user_id, user.pk)
@ -203,3 +203,80 @@ class UpdateTest(TestCase):
response.context['add_preview_formset'].forms[0].errors, response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']}, {'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']},
)

View File

@ -1,14 +1,13 @@
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
import logging import logging
import mimetypes
import re import re
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin 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 ( from constants.base import (
FILE_STATUS_CHOICES, FILE_STATUS_CHOICES,
FILE_TYPE_CHOICES, FILE_TYPE_CHOICES,
@ -137,8 +136,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
self.original_name = self.source.name self.original_name = self.source.name
if not self.content_type: if not self.content_type:
content_type, _ = mimetypes.guess_type(self.original_name) self.content_type = guess_mimetype_from_ext(self.original_name)
self.content_type = content_type
if self.content_type: if self.content_type:
if 'image' in self.content_type: if 'image' in self.content_type:
self.type = self.TYPES.IMAGE self.type = self.TYPES.IMAGE

View File

@ -1,12 +1,14 @@
from pathlib import Path from pathlib import Path
import hashlib import hashlib
import io import io
import os
import logging import logging
import zipfile import mimetypes
import os
import toml import toml
import zipfile
from lxml import etree from lxml import etree
import magic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent 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}") logger.error(f"Error extracting from archive: {e}")
return None 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

View File

@ -38,6 +38,7 @@ mistune==2.0.4
multidict==6.0.2 multidict==6.0.2
oauthlib==3.2.0 oauthlib==3.2.0
Pillow==9.2.0 Pillow==9.2.0
python-magic==0.4.27
requests==2.28.1 requests==2.28.1
requests-oauthlib==1.3.1 requests-oauthlib==1.3.1
semantic-version==2.10.0 semantic-version==2.10.0

View File

@ -1,17 +1,27 @@
[flake8] [flake8]
ignore= ignore=
D100, #: Missing docstring in public module #: Missing docstring in public module
D101, #: Missing docstring in public class D100,
D102, #: Missing docstring in public method #: Missing docstring in public class
D101,
#: Missing docstring in public method
D102,
D103, D103,
D107, #: Missing docstring in __init__ #: Missing docstring in __init__
D107,
D205, D205,
D105, #: Missing docstring in magic method #: Missing docstring in magic method
D203, #: 1 blank line required before class docstring D105,
D213, #: We adhere to D212 instead #: 1 blank line required before class docstring
D4, #: All section formatting, which seems to be impossible to comply D203,
W503, #: We adhere to W504 instead: line break should be before binary operator #: We adhere to D212 instead
D106, #: No docstring in public nested class (e.g. Meta) 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 extend-ignore = E203
max-line-length = 100 max-line-length = 100
per-file-ignores = per-file-ignores =