extensions-website/files/validators.py
Dalai Felinto baedbb00de Manifest: schema_version validator
Someone uploaded an extension with version 1.0.1, it should have failed
but we were not testing for specific values.
2024-03-29 19:00:32 +01:00

493 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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