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:
|
||
if VersionPermission.get_by_slug(permission):
|
||
continue
|
||
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',
|
||
)
|