Dalai Felinto
baedbb00de
Someone uploaded an extension with version 1.0.1, it should have failed but we were not testing for specific values.
493 lines
17 KiB
Python
493 lines
17 KiB
Python
from pathlib import Path
|
||
from semantic_version import Version
|
||
import logging
|
||
|
||
from django.utils.safestring import mark_safe
|
||
from django.utils.html import escape
|
||
from django.core.exceptions import ValidationError
|
||
from django.core.validators import FileExtensionValidator, validate_unicode_slug
|
||
|
||
from extensions.models import Extension, License, VersionPermission, Tag
|
||
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CustomFileExtensionValidator(FileExtensionValidator):
|
||
"""Allows extensions such as tar.gz."""
|
||
|
||
def __call__(self, value):
|
||
suffixes = Path(value.name).suffixes
|
||
# Possible extensions without a leading dot
|
||
extensions = {
|
||
''.join(suffixes[-1:])[1:].lower(),
|
||
''.join(suffixes)[1:].lower(),
|
||
''.join(suffixes[1:])[1:].lower(),
|
||
}
|
||
if self.allowed_extensions is not None and all(
|
||
extension not in self.allowed_extensions for extension in extensions
|
||
):
|
||
full_extension = ''.join(suffixes).lower()[1:]
|
||
raise ValidationError(
|
||
self.message,
|
||
code=self.code,
|
||
params={
|
||
"extension": full_extension,
|
||
"allowed_extensions": ", ".join(self.allowed_extensions),
|
||
"value": value,
|
||
},
|
||
)
|
||
|
||
|
||
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',
|
||
)
|