Oleg Komarov
6167f991f5
Fixes #109, We noticed a discrepancy in behavior between sqlite (case-insensitive like) and postgresql (case-sensitive like) because a lookup was happening against a `name` column, which is titlecased, but the permission ids (slugs) in manifest are lowercase. The fix is to use the `slug` field, and to perform an exact match, not a LIKE. This PR also make sure that slug fields in VersionPermission and License models are unique (doesn't do it for Tag, but we should fix that as well), and cleans up some unused code in the affected models. Reviewed-on: #115 Reviewed-by: Anna Sirota <railla@noreply.localhost>
507 lines
17 KiB
Python
507 lines
17 KiB
Python
from semantic_version import Version
|
||
import logging
|
||
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.validators import validate_unicode_slug
|
||
from django.utils.deconstruct import deconstructible
|
||
from django.utils.html import escape
|
||
from django.utils.safestring import mark_safe
|
||
|
||
from extensions.models import Extension, License, VersionPermission, Tag
|
||
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES
|
||
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@deconstructible
|
||
class FileMIMETypeValidator:
|
||
code = 'invalid_mimetype'
|
||
|
||
def __init__(self, allowed_mimetypes, message, code=None):
|
||
self.allowed_mimetypes = {_type.lower() for _type in allowed_mimetypes}
|
||
if message is not None:
|
||
self.message = message
|
||
if code is not None:
|
||
self.code = code
|
||
|
||
def __call__(self, value):
|
||
# Guess MIME-type based on extension first
|
||
mimetype_from_ext = guess_mimetype_from_ext(value.name)
|
||
if mimetype_from_ext not in self.allowed_mimetypes:
|
||
raise ValidationError(self.message, code=self.code)
|
||
# Guess MIME-type based on file's content
|
||
mimetype_from_bytes = guess_mimetype_from_content(value)
|
||
if mimetype_from_bytes not in self.allowed_mimetypes:
|
||
raise ValidationError(self.message, code=self.code)
|
||
if mimetype_from_ext != mimetype_from_bytes:
|
||
# This shouldn't happen, but libmagic's and mimetypes' mappings
|
||
# might differ from distro to distro.
|
||
logger.exception(
|
||
"MIME-type from extension (%s) doesn't match content (%s)",
|
||
mimetype_from_ext,
|
||
mimetype_from_bytes,
|
||
)
|
||
raise ValidationError(self.message, code=self.code)
|
||
|
||
def __eq__(self, other):
|
||
return (
|
||
isinstance(other, self.__class__)
|
||
and self.allowed_mimetypes == other.allowed_mimetypes
|
||
and self.message == other.message
|
||
and self.code == other.code
|
||
)
|
||
|
||
|
||
class ExtensionIDManifestValidator:
|
||
"""Make sure the extension id is valid:
|
||
* Extension id consists of Unicode letters, numbers or underscores.
|
||
* Neither hyphens nor spaces are supported.
|
||
* Each extension id most be unique across all extensions.
|
||
* All versions of an extension must have the same extension id.
|
||
"""
|
||
|
||
def __init__(self, manifest, extension_to_be_updated):
|
||
extension_id = manifest.get('id')
|
||
|
||
if extension_id is None:
|
||
raise ValidationError(
|
||
{
|
||
'source': [
|
||
mark_safe('Missing field in blender_manifest.toml: <code>id</code>'),
|
||
],
|
||
},
|
||
code='invalid',
|
||
)
|
||
|
||
if '-' in extension_id:
|
||
raise ValidationError(
|
||
{
|
||
'source': [
|
||
mark_safe(
|
||
"Invalid <code>id</code> from extension manifest: "
|
||
f'"{escape(extension_id)}". No hyphens are allowed.'
|
||
),
|
||
],
|
||
},
|
||
code='invalid',
|
||
)
|
||
|
||
try:
|
||
validate_unicode_slug(extension_id)
|
||
except ValidationError:
|
||
raise ValidationError(
|
||
{
|
||
'source': [
|
||
mark_safe(
|
||
f'Invalid <code>id</code> from extension manifest: '
|
||
f'"{escape(extension_id)}". '
|
||
f'Use a valid id consisting of Unicode letters, numbers or underscores.'
|
||
),
|
||
],
|
||
},
|
||
code='invalid',
|
||
)
|
||
|
||
if extension_to_be_updated is None:
|
||
if Extension.objects.filter(extension_id=extension_id).exists():
|
||
raise ValidationError(
|
||
{
|
||
'source': [
|
||
mark_safe(
|
||
f'The extension <code>id</code> in the manifest '
|
||
f'("{escape(extension_id)}") '
|
||
f'is already being used by another extension.'
|
||
),
|
||
],
|
||
},
|
||
code='invalid',
|
||
)
|
||
elif extension_to_be_updated.extension_id != extension_id:
|
||
raise ValidationError(
|
||
{
|
||
'source': [
|
||
mark_safe(
|
||
f'The extension <code>id</code> in the manifest '
|
||
f'("{escape(extension_id)}") doesn\'t match the expected one for'
|
||
f'this extension ("{escape(extension_to_be_updated.extension_id)}").'
|
||
),
|
||
],
|
||
},
|
||
code='invalid',
|
||
)
|
||
|
||
|
||
class ManifestFieldValidator:
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: object, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
assert not "ManifestFieldValidator must be inherited not to be used directly."
|
||
|
||
|
||
class SimpleValidator(ManifestFieldValidator):
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: object, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
if not hasattr(cls, '_type') or not hasattr(cls, '_type_name'):
|
||
assert not "SimpleValidator must be inherited not be used directly."
|
||
if type(value) != cls._type:
|
||
return mark_safe(
|
||
f'Manifest value error: <code>{name}</code> should be of type: {cls._type_name}'
|
||
)
|
||
|
||
|
||
class StringValidator(SimpleValidator):
|
||
_type = str
|
||
_type_name = 'Text: ""'
|
||
example = 'text'
|
||
|
||
|
||
class ListValidator(SimpleValidator):
|
||
_type = list
|
||
_type_name = 'List of values []'
|
||
example = ['string', 'another_string']
|
||
|
||
|
||
class LicenseValidator(ListValidator):
|
||
example = ['SPDX:GPL-2.0-or-later']
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
|
||
"""Return error message if there is any license that is not accepted by the site"""
|
||
is_error = False
|
||
error_message = ""
|
||
|
||
if type(value) != list:
|
||
is_error = True
|
||
|
||
for license in value:
|
||
if License.get_by_slug(license):
|
||
continue
|
||
is_error = True
|
||
break
|
||
|
||
if not is_error:
|
||
return
|
||
|
||
error_message = mark_safe(
|
||
f'Manifest value error: <code>license</code> expects a list of '
|
||
f'<a href="https://docs.blender.org/manual/en/dev/extensions/licenses.html">'
|
||
f'supported licenses</a>. e.g., {cls.example}.'
|
||
)
|
||
|
||
return error_message
|
||
|
||
|
||
class TagsValidatorBase:
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
|
||
"""Return error message if there is no tag, or if tag is not a valid one"""
|
||
is_error = False
|
||
type_name = EXTENSION_TYPE_SLUGS_SINGULAR[cls.type]
|
||
|
||
if type(value) != list:
|
||
is_error = True
|
||
else:
|
||
for tag in value:
|
||
if Tag.objects.filter(name=tag, type=cls.type):
|
||
continue
|
||
is_error = True
|
||
type_slug = manifest.get('type')
|
||
logger.info(f'Tag unavailable for {type_slug}: {tag}')
|
||
|
||
if not is_error:
|
||
return
|
||
|
||
error_message = (
|
||
f'Manifest value error: <code>tags</code> expects a list of '
|
||
f'<a href="https://docs.blender.org/manual/en/dev/extensions/tags.html" '
|
||
f'target="_blank"> supported {type_name} tags</a>. e.g., {cls.example}. '
|
||
)
|
||
return mark_safe(error_message)
|
||
|
||
|
||
class TagsAddonsValidator(TagsValidatorBase):
|
||
example = ['Animation', 'Sequencer']
|
||
type = EXTENSION_TYPE_CHOICES.BPY
|
||
|
||
|
||
class TagsThemesValidator(TagsValidatorBase):
|
||
example = ['Dark', 'Accessibility']
|
||
type = EXTENSION_TYPE_CHOICES.THEME
|
||
|
||
|
||
class TagsValidator:
|
||
example = ['User Interface']
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
|
||
"""Return error message if there is no tag, or if tag is not a valid one"""
|
||
tags_lookup = {
|
||
EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]: TagsAddonsValidator,
|
||
EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]: TagsThemesValidator,
|
||
}
|
||
|
||
type_slug = manifest.get('type')
|
||
meta_class = tags_lookup.get(type_slug)
|
||
|
||
if meta_class is None:
|
||
return
|
||
|
||
return meta_class.validate(name=name, value=value, manifest=manifest)
|
||
|
||
|
||
class TypeValidator:
|
||
example = 'add-on'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if doesn´t contain one of the valid types."""
|
||
is_error = False
|
||
|
||
if (type(value) != str) or (not value):
|
||
is_error = True
|
||
elif value not in EXTENSION_TYPE_SLUGS_SINGULAR.values():
|
||
is_error = True
|
||
logger.info(f'Type unavailable: {value}')
|
||
|
||
if not is_error:
|
||
return
|
||
|
||
error_message = (
|
||
f'Manifest value error: <code>{name}</code> expects one of the supported types. '
|
||
f'The supported types are: '
|
||
)
|
||
|
||
for _type in EXTENSION_TYPE_SLUGS_SINGULAR.values():
|
||
error_message += f'"{_type}", '
|
||
error_message = error_message[:-2] + '.'
|
||
|
||
return mark_safe(error_message)
|
||
|
||
|
||
class PermissionsValidator:
|
||
example = ['files', 'network']
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if doesn´t contain a valid list of permissions."""
|
||
is_error = False
|
||
|
||
extension_type = manifest.get('type')
|
||
is_theme = extension_type == EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]
|
||
is_bpy = extension_type == EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]
|
||
|
||
if is_theme:
|
||
error_message = mark_safe(
|
||
'Manifest value error: <code>permissions</code> not required for themes.'
|
||
)
|
||
return error_message
|
||
|
||
# Let the wrong type error be handled elsewhere.
|
||
if not is_bpy:
|
||
return
|
||
|
||
if (type(value) != list) or (not value):
|
||
is_error = True
|
||
else:
|
||
for permission in value:
|
||
try:
|
||
VersionPermission.get_by_slug(permission)
|
||
except VersionPermission.DoesNotExist:
|
||
is_error = True
|
||
logger.info(f'Permission unavailable: {permission}')
|
||
|
||
if not is_error:
|
||
return
|
||
|
||
error_message = (
|
||
f'Manifest value error: <code>permissions</code> expects a list of '
|
||
f'supported permissions. e.g.: {cls.example}. The supported permissions are: '
|
||
)
|
||
|
||
for permission_ob in VersionPermission.objects.all():
|
||
error_message += f'{permission_ob.slug}, '
|
||
error_message = error_message[:-2] + '.'
|
||
|
||
return mark_safe(error_message)
|
||
|
||
|
||
class VersionValidator:
|
||
example = '1.0.0'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
try:
|
||
Version(value)
|
||
except (ValueError, TypeError):
|
||
# ValueError happens when passing an invalid version, like "2.9"
|
||
# TypeError happens when e.g., passing an integer
|
||
return mark_safe(
|
||
f'Manifest value error: <code>{name}</code> should follow a '
|
||
f'<a href="https://semver.org/" target="_blank">semantic version</a>. '
|
||
f'e.g., "{cls.example}"'
|
||
)
|
||
|
||
|
||
class SchemaVersionValidator(VersionValidator):
|
||
example = '1.0.0'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
if err_message := super().validate(name=name, value=value, manifest=manifest):
|
||
return err_message
|
||
|
||
valid_schemas = {
|
||
"1.0.0",
|
||
}
|
||
|
||
if value not in valid_schemas:
|
||
# TODO make a user manual page with the list of the different schemas.
|
||
return mark_safe(
|
||
f'Manifest value error: <code>schema</code> version ({escape(value)}) '
|
||
f'<a href="https://docs.blender.org/manual/en/dev/extensions/'
|
||
f'getting_started.html#manifest" target="_blank">not supported</a>.'
|
||
)
|
||
|
||
|
||
class VersionVersionValidator(VersionValidator):
|
||
example = '1.0.0'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
if err_message := super().validate(name=name, value=value, manifest=manifest):
|
||
return err_message
|
||
|
||
extension = Extension.objects.filter(extension_id=manifest.get("id")).first()
|
||
|
||
# If the extension wasn't created yet, any version is valid
|
||
if not extension:
|
||
return
|
||
|
||
version = extension.versions.filter(version=value).first()
|
||
|
||
if version:
|
||
return mark_safe(
|
||
f'The version {value} was already uploaded for this extension '
|
||
f'({extension.name})'
|
||
)
|
||
|
||
|
||
class VersionMinValidator(VersionValidator):
|
||
example = '4.2.0'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if cannot validate, otherwise returns nothing"""
|
||
if err_message := super().validate(name=name, value=value, manifest=manifest):
|
||
return err_message
|
||
|
||
# Extensions were created in 4.2.0
|
||
if Version(value) < Version('4.2.0'):
|
||
return mark_safe(
|
||
'Manifest value error: <code>blender_version_min</code> should be at least "4.2.0"'
|
||
)
|
||
|
||
|
||
class VersionMaxValidator(VersionValidator):
|
||
example = '4.2.0'
|
||
|
||
|
||
class TaglineValidator(StringValidator):
|
||
example = 'Short description of my extension'
|
||
|
||
@classmethod
|
||
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
|
||
"""Return error message if has period at the end of the line or is too long."""
|
||
if err_message := super().validate(name=name, value=value, manifest=manifest):
|
||
return err_message
|
||
|
||
if not value:
|
||
return mark_safe('Manifest value error: <code>tagline</code> cannot be empty.')
|
||
|
||
if value[-1] in {'.', '!', '?'}:
|
||
return mark_safe(
|
||
"Manifest value error: <code>tagline</code> cannot end with any punctuation (.!?)."
|
||
)
|
||
|
||
# TODO: get this from the model, the following code was supposed to work, however
|
||
# it does not (it did for Extensions).
|
||
# Version._meta.get_field('tagline').max_length
|
||
max_length = 64
|
||
if len(value) > max_length:
|
||
return mark_safe(
|
||
f'Manifest value error: <code>tagline</code> is too long ({len(value)}), '
|
||
f'max-length: {max_length} characters'
|
||
)
|
||
|
||
|
||
class ManifestValidator:
|
||
"""Make sure the manifest has all the expected fields."""
|
||
|
||
mandatory_fields = {
|
||
'blender_version_min': VersionMinValidator,
|
||
'id': StringValidator,
|
||
'license': LicenseValidator,
|
||
'maintainer': StringValidator,
|
||
'name': StringValidator,
|
||
'schema_version': SchemaVersionValidator,
|
||
'tagline': TaglineValidator,
|
||
'type': TypeValidator,
|
||
'version': VersionVersionValidator,
|
||
}
|
||
optional_fields = {
|
||
'blender_version_max': VersionMaxValidator,
|
||
'website': StringValidator,
|
||
'copyright': ListValidator,
|
||
'permissions': PermissionsValidator,
|
||
'tags': TagsValidator,
|
||
}
|
||
all_fields = {**mandatory_fields, **optional_fields}
|
||
|
||
def __init__(self, manifest):
|
||
missing_fields = []
|
||
wrong_fields = []
|
||
|
||
for field_name, field_validator in self.mandatory_fields.items():
|
||
field_value = manifest.get(field_name)
|
||
if field_value is None:
|
||
missing_fields.append(field_name)
|
||
elif err_message := field_validator.validate(
|
||
name=field_name, value=field_value, manifest=manifest
|
||
):
|
||
wrong_fields.append(err_message)
|
||
|
||
for field_name, field_validator in self.optional_fields.items():
|
||
field_value = manifest.get(field_name)
|
||
if field_value is None:
|
||
continue
|
||
elif err_message := field_validator.validate(
|
||
name=field_name, value=field_value, manifest=manifest
|
||
):
|
||
wrong_fields.append(err_message)
|
||
|
||
if not (missing_fields or wrong_fields):
|
||
return
|
||
|
||
errors = []
|
||
|
||
if missing_fields:
|
||
errors.append(
|
||
'The following values are missing from the manifest file: '
|
||
f'{", ".join(missing_fields)}'
|
||
)
|
||
|
||
# Add the wrong field error messages to the general errors.
|
||
errors += wrong_fields
|
||
|
||
raise ValidationError(
|
||
{
|
||
'source': errors,
|
||
},
|
||
code='invalid',
|
||
)
|