aboutsummaryrefslogtreecommitdiff
# SPDX-License-Identifier: GPL-3.0-or-later

# Haketilo proxy data and configuration (interface definition through abstract
# class).
#
# This file is part of Hydrilla&Haketilo.
#
# Copyright (C) 2022 Wojtek Kosior
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
#
# I, Wojtek Kosior, thereby promise not to sue for violation of this
# file's license. Although I request that you do not make use of this
# code in a proprietary program, I am not going to enforce this in
# court.

"""
This module defines API for keeping track of all settings, rules, mappings and
resources.
"""

import dataclasses as dc
import typing as t

from pathlib import Path
from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime

from immutables import Map

from ..exceptions import HaketiloException
from ..versions import VerTuple
from ..url_patterns import ParsedPattern
from .. import item_infos
from .simple_dependency_satisfying import ImpossibleSituation


class EnabledStatus(Enum):
    """
    ENABLED - User wished to always apply given mapping when it matches site's
        URL.

    DISABLED - User wished to never apply given mapping.

    NO_MARK - User has not configured given mapping.
    """
    ENABLED  = 'E'
    DISABLED = 'D'
    NO_MARK  = 'N'


class FrozenStatus(Enum):
    """
    EXACT_VERSION - User wished to always use the same version of a mapping.

    REPOSITORY - User wished to always use a version of the mapping from the
        same repository.

    NOT_FROZEN - User did not restrict updates of the mapping.
    """
    EXACT_VERSION = 'E'
    REPOSITORY    = 'R'
    NOT_FROZEN    = 'N'

    @staticmethod
    def make(letter: t.Optional[str]) -> t.Optional['FrozenStatus']:
        if letter is None:
            return None

        return FrozenStatus(letter)


class InstalledStatus(Enum):
    """
    INSTALLED - Mapping's all files are present and mapping data is not going to
        be automatically removed.

    NOT_INSTALLED - Some of the mapping's files might be absent. Mapping can be
        automatically removed if it is orphaned.

    FAILED_TO_INSTALL - Same as "NOT_INSTALLED" but we additionally know that
        the last automatic attempt to install mapping's files from repository
        was unsuccessful.
    """
    INSTALLED         = 'I'
    NOT_INSTALLED     = 'N'
    FAILED_TO_INSTALL = 'F'


class ActiveStatus(Enum):
    """
    REQUIRED - Mapping version got active to fulfill a requirement of some (this
        or another) explicitly enabled mapping.

    AUTO - Mapping version was activated automatically.

    NOT_ACTIVE - Mapping version is not currently being used.
    """
    REQUIRED   = 'R'
    AUTO       = 'A'
    NOT_ACTIVE = 'N'


@dc.dataclass(frozen=True, unsafe_hash=True)
class Ref:
    """...."""
    id: str

    def __post_init__(self):
        assert isinstance(self.id, str)


RefType = t.TypeVar('RefType', bound=Ref)

class Store(ABC, t.Generic[RefType]):
    @abstractmethod
    def get(self, id) -> RefType:
        ...


class RulePatternInvalid(HaketiloException):
    pass

@dc.dataclass(frozen=True)
class RuleDisplayInfo:
    ref:           'RuleRef'
    pattern:       str
    allow_scripts: bool

# mypy needs to be corrected:
# https://stackoverflow.com/questions/70999513/conflict-between-mix-ins-for-abstract-dataclasses/70999704#70999704
@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class RuleRef(Ref):
    @abstractmethod
    def remove(self) -> None:
        ...

    @abstractmethod
    def update(
            self,
            *,
            pattern: t.Optional[str] = None,
            allow: t.Optional[bool] = None
    ) -> None:
        ...

    @abstractmethod
    def get_display_info(self) -> RuleDisplayInfo:
        ...

class RuleStore(Store[RuleRef]):
    @abstractmethod
    def get_display_infos(self, allow: t.Optional[bool] = None) \
        -> t.Sequence[RuleDisplayInfo]:
        ...

    @abstractmethod
    def add(self, pattern: str, allow: bool) -> RuleRef:
        ...

    @abstractmethod
    def get_by_pattern(self, pattern: str) -> RuleRef:
        ...


class RepoNameInvalid(HaketiloException):
    pass

class RepoNameTaken(HaketiloException):
    pass

class RepoUrlInvalid(HaketiloException):
    pass

class RepoCommunicationError(HaketiloException):
    pass

@dc.dataclass(frozen=True)
class FileInstallationError(HaketiloException):
    repo_id: str
    sha256:  str

@dc.dataclass(frozen=True)
class FileIntegrityError(FileInstallationError):
    invalid_sha256: str

@dc.dataclass(frozen=True)
class FileMissingError(FileInstallationError):
    pass

class RepoApiVersionUnsupported(HaketiloException):
    pass

@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class RepoRef(Ref):
    """...."""
    @abstractmethod
    def remove(self) -> None:
        """...."""
        ...

    @abstractmethod
    def update(
            self,
            *,
            name: t.Optional[str] = None,
            url:  t.Optional[str] = None
    ) -> None:
        """...."""
        ...

    @abstractmethod
    def refresh(self) -> None:
        """...."""
        ...

    @abstractmethod
    def get_display_info(self) -> 'RepoDisplayInfo':
        ...

@dc.dataclass(frozen=True)
class RepoDisplayInfo:
    ref:               RepoRef
    is_local_semirepo: bool
    name:              str
    url:               str
    deleted:           bool
    last_refreshed:    t.Optional[datetime]
    resource_count:    int
    mapping_count:     int

class RepoStore(Store[RepoRef]):
    @abstractmethod
    def get_display_infos(self, include_deleted: bool = False) -> \
        t.Sequence[RepoDisplayInfo]:
        ...

    @abstractmethod
    def add(self, name: str, url: str) -> RepoRef:
        ...


@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class RepoIterationRef(Ref):
    """...."""
    pass


@dc.dataclass(frozen=True)
class FileData:
    mime_type: str
    name:      str
    contents:  bytes


@dc.dataclass(frozen=True)
class MappingDisplayInfo(item_infos.CorrespondsToMappingDCMixin):
    ref:            'MappingRef'
    identifier:     str
    enabled:        EnabledStatus
    frozen:         t.Optional[FrozenStatus]
    active_version: t.Optional['MappingVersionDisplayInfo']

@dc.dataclass(frozen=True)
class RichMappingDisplayInfo(MappingDisplayInfo):
    all_versions: t.Sequence['MappingVersionDisplayInfo']

@dc.dataclass(frozen=True)
class MappingVersionDisplayInfo(item_infos.CorrespondsToMappingDCMixin):
    ref:             'MappingVersionRef'
    info:            item_infos.MappingInfo
    installed:       InstalledStatus
    active:          ActiveStatus
    is_orphan:       bool
    is_local:        bool

@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class MappingRef(Ref, item_infos.CorrespondsToMappingDCMixin):
    """...."""
    @abstractmethod
    def update_status(
            self,
            enabled: EnabledStatus,
            frozen:  t.Optional[FrozenStatus] = None
    ) -> None:
        ...

    @abstractmethod
    def get_display_info(self) -> RichMappingDisplayInfo:
        ...


class MappingStore(Store[MappingRef]):
    @abstractmethod
    def get_display_infos(self) -> t.Sequence[MappingDisplayInfo]:
        ...

    @abstractmethod
    def get_by_identifier(self, identifier: str) -> MappingRef:
        ...

@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class MappingVersionRef(Ref, item_infos.CorrespondsToMappingDCMixin):
    @abstractmethod
    def install(self) -> None:
        ...

    @abstractmethod
    def uninstall(self) -> t.Optional['MappingVersionRef']:
        ...

    @abstractmethod
    def ensure_depended_items_installed(self) -> None:
        ...

    @abstractmethod
    def update_mapping_status(
            self,
            enabled: EnabledStatus,
            frozen:  t.Optional[FrozenStatus] = None
    ) -> None:
        ...

    @abstractmethod
    def get_license_file(self, name: str) -> FileData:
        ...

    @abstractmethod
    def get_upstream_license_file_url(self, name: str) -> str:
        ...

    @abstractmethod
    def get_required_mapping(self, identifier: str) -> 'MappingVersionRef':
        ...

    @abstractmethod
    def get_payload_resource(self, pattern: str, identifier: str) \
        -> 'ResourceVersionRef':
        ...

    @abstractmethod
    def get_item_display_info(self) -> RichMappingDisplayInfo:
        ...

class MappingVersionStore(Store[MappingVersionRef]):
    pass


@dc.dataclass(frozen=True)
class ResourceDisplayInfo(item_infos.CorrespondsToResourceDCMixin):
    ref:            'ResourceRef'
    identifier:     str

@dc.dataclass(frozen=True)
class RichResourceDisplayInfo(ResourceDisplayInfo):
    all_versions: t.Sequence['ResourceVersionDisplayInfo']

@dc.dataclass(frozen=True)
class ResourceVersionDisplayInfo(item_infos.CorrespondsToResourceDCMixin):
    ref:             'ResourceVersionRef'
    info:            item_infos.ResourceInfo
    installed:       InstalledStatus
    active:          ActiveStatus
    is_orphan:       bool
    is_local:        bool

@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class ResourceRef(Ref, item_infos.CorrespondsToResourceDCMixin):
    @abstractmethod
    def get_display_info(self) -> RichResourceDisplayInfo:
        ...

class ResourceStore(Store[ResourceRef]):
    @abstractmethod
    def get_display_infos(self) -> t.Sequence[ResourceDisplayInfo]:
        ...

    @abstractmethod
    def get_by_identifier(self, identifier: str) -> ResourceRef:
        ...


@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class ResourceVersionRef(Ref, item_infos.CorrespondsToResourceDCMixin):
    @abstractmethod
    def install(self) -> None:
        ...

    @abstractmethod
    def uninstall(self) -> t.Optional['ResourceVersionRef']:
        ...

    @abstractmethod
    def get_license_file(self, name: str) -> FileData:
        ...

    @abstractmethod
    def get_resource_file(self, name: str) -> FileData:
        ...

    @abstractmethod
    def get_upstream_license_file_url(self, name: str) -> str:
        ...

    @abstractmethod
    def get_upstream_resource_file_url(self, name: str) -> str:
        ...

    @abstractmethod
    def get_dependency(self, identifier: str) -> 'ResourceVersionRef':
        ...

    @abstractmethod
    def get_item_display_info(self) -> RichResourceDisplayInfo:
        ...

class ResourceVersionStore(Store[ResourceVersionRef]):
    pass


@dc.dataclass(frozen=True)
class PayloadKey:
    """...."""
    ref: 'PayloadRef'

    mapping_identifier: str

    def __lt__(self, other: 'PayloadKey') -> bool:
        """...."""
        return self.mapping_identifier < other.mapping_identifier

@dc.dataclass(frozen=True)
class PayloadData:
    """...."""
    ref: 'PayloadRef'

    explicitly_enabled:    bool
    unique_token:          str
    mapping_identifier:    str
    pattern:               str
    pattern_path_segments: tuple[str, ...]
    eval_allowed:          bool
    cors_bypass_allowed:   bool
    global_secret:         bytes

@dc.dataclass(frozen=True)
class PayloadDisplayInfo:
    ref: 'PayloadRef'

    mapping_info: MappingVersionDisplayInfo
    pattern:      str
    has_problems: bool

@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class PayloadRef(Ref):
    """...."""
    @abstractmethod
    def get_data(self) -> PayloadData:
        """...."""
        ...

    @abstractmethod
    def has_problems(self) -> bool:
        ...

    @abstractmethod
    def get_display_info(self) -> PayloadDisplayInfo:
        ...

    @abstractmethod
    def ensure_items_installed(self) -> None:
        """...."""
        ...

    @abstractmethod
    def get_script_paths(self) \
        -> t.Iterable[t.Sequence[str]]:
        """...."""
        ...

    @abstractmethod
    def get_file_data(self, path: t.Sequence[str]) \
        -> t.Optional[FileData]:
        """...."""
        ...

class PayloadStore(Store[PayloadRef]):
    pass


class MappingUseMode(Enum):
    """
    AUTO - Apply mappings except for those explicitly disabled.

    WHEN_ENABLED - Only apply mappings explicitly marked as enabled. Don't apply
        unmarked nor explicitly disabled mappings.

    QUESTION - Automatically apply mappings that are explicitly enabled. Ask
        whether to enable unmarked mappings. Don't apply explicitly disabled
        ones.
    """
    AUTO         = 'A'
    WHEN_ENABLED = 'W'
    QUESTION     = 'Q'


class PopupStyle(Enum):
    """
    DIALOG - Make popup open inside an iframe on the current page.

    TAB - Make popup open in a new tab.
    """
    DIALOG = 'D'
    TAB    = 'T'

@dc.dataclass(frozen=True)
class PopupSettings:
    # We'll implement button later.
    #button_trigger:   bool
    keyboard_trigger: bool
    style:            PopupStyle

    @property
    def popup_enabled(self) -> bool:
        return self.keyboard_trigger #or self.button_trigger

@dc.dataclass(frozen=True)
class HaketiloGlobalSettings:
    """...."""
    mapping_use_mode:      MappingUseMode
    default_allow_scripts: bool
    advanced_user:         bool
    repo_refresh_seconds:  int
    locale:                t.Optional[str]

    default_popup_jsallowed: PopupSettings
    default_popup_jsblocked: PopupSettings
    default_popup_payloadon: PopupSettings


class Logger(ABC):
    @abstractmethod
    def warn(self, msg: str) -> None:
        ...


class MissingItemError(ValueError):
    """...."""
    pass


@dc.dataclass(frozen=True)
class OrphanItemsStats:
    mappings:  int
    resources: int


class HaketiloState(ABC):
    """...."""
    @abstractmethod
    def import_items(self, malcontent_path: Path) -> None:
        ...

    @abstractmethod
    def count_orphan_items(self) -> OrphanItemsStats:
        ...

    @abstractmethod
    def prune_orphan_items(self) -> None:
        ...

    @abstractmethod
    def rule_store(self) -> RuleStore:
        ...

    @abstractmethod
    def repo_store(self) -> RepoStore:
        """...."""
        ...

    @abstractmethod
    def mapping_store(self) -> MappingStore:
        ...

    @abstractmethod
    def mapping_version_store(self) -> MappingVersionStore:
        ...

    @abstractmethod
    def resource_store(self) -> ResourceStore:
        ...

    @abstractmethod
    def resource_version_store(self) -> ResourceVersionStore:
        ...

    @abstractmethod
    def payload_store(self) -> PayloadStore:
        ...

    @abstractmethod
    def get_secret(self) -> bytes:
        ...

    @abstractmethod
    def get_settings(self) -> HaketiloGlobalSettings:
        """...."""
        ...

    @abstractmethod
    def update_settings(
            self,
            *,
            mapping_use_mode:       t.Optional[MappingUseMode]    = None,
            default_allow_scripts:  t.Optional[bool]              = None,
            advanced_user:          t.Optional[bool]              = None,
            repo_refresh_seconds:   t.Optional[int]               = None,
            locale:                 t.Optional[str]               = None,
            default_popup_settings: t.Mapping[str, PopupSettings] = {}
    ) -> None:
        ...

    @property
    @abstractmethod
    def listen_host(self) -> str:
        ...

    @property
    @abstractmethod
    def listen_port(self) -> int:
        ...

    @abstractmethod
    def launch_browser(self) -> bool:
        ...

    @property
    @abstractmethod
    def logger(self) -> Logger:
        ...