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
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
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.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."""

View File

@ -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')

View File

@ -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

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">
</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>

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'],
}
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']},
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 =