extensions-website/files/validators.py
Oleg Komarov 6167f991f5 Don't treat permission name as a machine readable field, use slug instead (#115)
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>
2024-05-06 17:40:06 +02:00

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