mirror of https://github.com/morpheus65535/bazarr
1392 lines
51 KiB
Python
1392 lines
51 KiB
Python
# -*- coding: utf-8 -*-
|
|
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import os
|
|
import re
|
|
import yaml
|
|
import time
|
|
|
|
from .. import plugins
|
|
from .. import common
|
|
from ..AppriseAsset import AppriseAsset
|
|
from ..URLBase import URLBase
|
|
from ..utils import GET_SCHEMA_RE
|
|
from ..utils import parse_list
|
|
from ..utils import parse_bool
|
|
from ..utils import parse_urls
|
|
from ..utils import cwe312_url
|
|
|
|
# Test whether token is valid or not
|
|
VALID_TOKEN = re.compile(
|
|
r'(?P<token>[a-z0-9][a-z0-9_]+)', re.I)
|
|
|
|
|
|
class ConfigBase(URLBase):
|
|
"""
|
|
This is the base class for all supported configuration sources
|
|
"""
|
|
|
|
# The Default Encoding to use if not otherwise detected
|
|
encoding = 'utf-8'
|
|
|
|
# The default expected configuration format unless otherwise
|
|
# detected by the sub-modules
|
|
default_config_format = common.ConfigFormat.TEXT
|
|
|
|
# This is only set if the user overrides the config format on the URL
|
|
# this should always initialize itself as None
|
|
config_format = None
|
|
|
|
# Don't read any more of this amount of data into memory as there is no
|
|
# reason we should be reading in more. This is more of a safe guard then
|
|
# anything else. 128KB (131072B)
|
|
max_buffer_size = 131072
|
|
|
|
# By default all configuration is not includable using the 'include'
|
|
# line found in configuration files.
|
|
allow_cross_includes = common.ContentIncludeMode.NEVER
|
|
|
|
# the config path manages the handling of relative include
|
|
config_path = os.getcwd()
|
|
|
|
def __init__(self, cache=True, recursion=0, insecure_includes=False,
|
|
**kwargs):
|
|
"""
|
|
Initialize some general logging and common server arguments that will
|
|
keep things consistent when working with the configurations that
|
|
inherit this class.
|
|
|
|
By default we cache our responses so that subsiquent calls does not
|
|
cause the content to be retrieved again. For local file references
|
|
this makes no difference at all. But for remote content, this does
|
|
mean more then one call can be made to retrieve the (same) data. This
|
|
method can be somewhat inefficient if disabled. Only disable caching
|
|
if you understand the consequences.
|
|
|
|
You can alternatively set the cache value to an int identifying the
|
|
number of seconds the previously retrieved can exist for before it
|
|
should be considered expired.
|
|
|
|
recursion defines how deep we recursively handle entries that use the
|
|
`include` keyword. This keyword requires us to fetch more configuration
|
|
from another source and add it to our existing compilation. If the
|
|
file we remotely retrieve also has an `include` reference, we will only
|
|
advance through it if recursion is set to 2 deep. If set to zero
|
|
it is off. There is no limit to how high you set this value. It would
|
|
be recommended to keep it low if you do intend to use it.
|
|
|
|
insecure_include by default are disabled. When set to True, all
|
|
Apprise Config files marked to be in STRICT mode are treated as being
|
|
in ALWAYS mode.
|
|
|
|
Take a file:// based configuration for example, only a file:// based
|
|
configuration can include another file:// based one. because it is set
|
|
to STRICT mode. If an http:// based configuration file attempted to
|
|
include a file:// one it woul fail. However this include would be
|
|
possible if insecure_includes is set to True.
|
|
|
|
There are cases where a self hosting apprise developer may wish to load
|
|
configuration from memory (in a string format) that contains 'include'
|
|
entries (even file:// based ones). In these circumstances if you want
|
|
these 'include' entries to be honored, this value must be set to True.
|
|
"""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
# Tracks the time the content was last retrieved on. This place a role
|
|
# for cases where we are not caching our response and are required to
|
|
# re-retrieve our settings.
|
|
self._cached_time = None
|
|
|
|
# Tracks previously loaded content for speed
|
|
self._cached_servers = None
|
|
|
|
# Initialize our recursion value
|
|
self.recursion = recursion
|
|
|
|
# Initialize our insecure_includes flag
|
|
self.insecure_includes = insecure_includes
|
|
|
|
if 'encoding' in kwargs:
|
|
# Store the encoding
|
|
self.encoding = kwargs.get('encoding')
|
|
|
|
if 'format' in kwargs \
|
|
and isinstance(kwargs['format'], str):
|
|
# Store the enforced config format
|
|
self.config_format = kwargs.get('format').lower()
|
|
|
|
if self.config_format not in common.CONFIG_FORMATS:
|
|
# Simple error checking
|
|
err = 'An invalid config format ({}) was specified.'.format(
|
|
self.config_format)
|
|
self.logger.warning(err)
|
|
raise TypeError(err)
|
|
|
|
# Set our cache flag; it can be True or a (positive) integer
|
|
try:
|
|
self.cache = cache if isinstance(cache, bool) else int(cache)
|
|
if self.cache < 0:
|
|
err = 'A negative cache value ({}) was specified.'.format(
|
|
cache)
|
|
self.logger.warning(err)
|
|
raise TypeError(err)
|
|
|
|
except (ValueError, TypeError):
|
|
err = 'An invalid cache value ({}) was specified.'.format(cache)
|
|
self.logger.warning(err)
|
|
raise TypeError(err)
|
|
|
|
return
|
|
|
|
def servers(self, asset=None, **kwargs):
|
|
"""
|
|
Performs reads loaded configuration and returns all of the services
|
|
that could be parsed and loaded.
|
|
|
|
"""
|
|
|
|
if not self.expired():
|
|
# We already have cached results to return; use them
|
|
return self._cached_servers
|
|
|
|
# Our cached response object
|
|
self._cached_servers = list()
|
|
|
|
# read() causes the child class to do whatever it takes for the
|
|
# config plugin to load the data source and return unparsed content
|
|
# None is returned if there was an error or simply no data
|
|
content = self.read(**kwargs)
|
|
if not isinstance(content, str):
|
|
# Set the time our content was cached at
|
|
self._cached_time = time.time()
|
|
|
|
# Nothing more to do; return our empty cache list
|
|
return self._cached_servers
|
|
|
|
# Our Configuration format uses a default if one wasn't one detected
|
|
# or enfored.
|
|
config_format = \
|
|
self.default_config_format \
|
|
if self.config_format is None else self.config_format
|
|
|
|
# Dynamically load our parse_ function based on our config format
|
|
fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format))
|
|
|
|
# Initialize our asset object
|
|
asset = asset if isinstance(asset, AppriseAsset) else self.asset
|
|
|
|
# Execute our config parse function which always returns a tuple
|
|
# of our servers and our configuration
|
|
servers, configs = fn(content=content, asset=asset)
|
|
self._cached_servers.extend(servers)
|
|
|
|
# Configuration files were detected; recursively populate them
|
|
# If we have been configured to do so
|
|
for url in configs:
|
|
|
|
if self.recursion > 0:
|
|
# Attempt to acquire the schema at the very least to allow
|
|
# our configuration based urls.
|
|
schema = GET_SCHEMA_RE.match(url)
|
|
if schema is None:
|
|
# Plan B is to assume we're dealing with a file
|
|
schema = 'file'
|
|
if not os.path.isabs(url):
|
|
# We're dealing with a relative path; prepend
|
|
# our current config path
|
|
url = os.path.join(self.config_path, url)
|
|
|
|
url = '{}://{}'.format(schema, URLBase.quote(url))
|
|
|
|
else:
|
|
# Ensure our schema is always in lower case
|
|
schema = schema.group('schema').lower()
|
|
|
|
# Some basic validation
|
|
if schema not in common.CONFIG_SCHEMA_MAP:
|
|
ConfigBase.logger.warning(
|
|
'Unsupported include schema {}.'.format(schema))
|
|
continue
|
|
|
|
# CWE-312 (Secure Logging) Handling
|
|
loggable_url = url if not asset.secure_logging \
|
|
else cwe312_url(url)
|
|
|
|
# Parse our url details of the server object as dictionary
|
|
# containing all of the information parsed from our URL
|
|
results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url)
|
|
if not results:
|
|
# Failed to parse the server URL
|
|
self.logger.warning(
|
|
'Unparseable include URL {}'.format(loggable_url))
|
|
continue
|
|
|
|
# Handle cross inclusion based on allow_cross_includes rules
|
|
if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes ==
|
|
common.ContentIncludeMode.STRICT
|
|
and schema not in self.schemas()
|
|
and not self.insecure_includes) or \
|
|
common.CONFIG_SCHEMA_MAP[schema] \
|
|
.allow_cross_includes == \
|
|
common.ContentIncludeMode.NEVER:
|
|
|
|
# Prevent the loading if insecure base protocols
|
|
ConfigBase.logger.warning(
|
|
'Including {}:// based configuration is prohibited. '
|
|
'Ignoring URL {}'.format(schema, loggable_url))
|
|
continue
|
|
|
|
# Prepare our Asset Object
|
|
results['asset'] = asset
|
|
|
|
# No cache is required because we're just lumping this in
|
|
# and associating it with the cache value we've already
|
|
# declared (prior to our recursion)
|
|
results['cache'] = False
|
|
|
|
# Recursion can never be parsed from the URL; we decrement
|
|
# it one level
|
|
results['recursion'] = self.recursion - 1
|
|
|
|
# Insecure Includes flag can never be parsed from the URL
|
|
results['insecure_includes'] = self.insecure_includes
|
|
|
|
try:
|
|
# Attempt to create an instance of our plugin using the
|
|
# parsed URL information
|
|
cfg_plugin = \
|
|
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
|
|
|
|
except Exception as e:
|
|
# the arguments are invalid or can not be used.
|
|
self.logger.warning(
|
|
'Could not load include URL: {}'.format(loggable_url))
|
|
self.logger.debug('Loading Exception: {}'.format(str(e)))
|
|
continue
|
|
|
|
# if we reach here, we can now add this servers found
|
|
# in this configuration file to our list
|
|
self._cached_servers.extend(
|
|
cfg_plugin.servers(asset=asset))
|
|
|
|
# We no longer need our configuration object
|
|
del cfg_plugin
|
|
|
|
else:
|
|
# CWE-312 (Secure Logging) Handling
|
|
loggable_url = url if not asset.secure_logging \
|
|
else cwe312_url(url)
|
|
|
|
self.logger.debug(
|
|
'Recursion limit reached; ignoring Include URL: %s',
|
|
loggable_url)
|
|
|
|
if self._cached_servers:
|
|
self.logger.info(
|
|
'Loaded {} entries from {}'.format(
|
|
len(self._cached_servers),
|
|
self.url(privacy=asset.secure_logging)))
|
|
else:
|
|
self.logger.warning(
|
|
'Failed to load Apprise configuration from {}'.format(
|
|
self.url(privacy=asset.secure_logging)))
|
|
|
|
# Set the time our content was cached at
|
|
self._cached_time = time.time()
|
|
|
|
return self._cached_servers
|
|
|
|
def read(self):
|
|
"""
|
|
This object should be implimented by the child classes
|
|
|
|
"""
|
|
return None
|
|
|
|
def expired(self):
|
|
"""
|
|
Simply returns True if the configuration should be considered
|
|
as expired or False if content should be retrieved.
|
|
"""
|
|
if isinstance(self._cached_servers, list) and self.cache:
|
|
# We have enough reason to look further into our cached content
|
|
# and verify it has not expired.
|
|
if self.cache is True:
|
|
# we have not expired, return False
|
|
return False
|
|
|
|
# Verify our cache time to determine whether we will get our
|
|
# content again.
|
|
age_in_sec = time.time() - self._cached_time
|
|
if age_in_sec <= self.cache:
|
|
# We have not expired; return False
|
|
return False
|
|
|
|
# If we reach here our configuration should be considered
|
|
# missing and/or expired.
|
|
return True
|
|
|
|
@staticmethod
|
|
def __normalize_tag_groups(group_tags):
|
|
"""
|
|
Used to normalize a tag assign map which looks like:
|
|
{
|
|
'group': set('{tag1}', '{group1}', '{tag2}'),
|
|
'group1': set('{tag2}','{tag3}'),
|
|
}
|
|
|
|
Then normalized it (merging groups); with respect to the above, the
|
|
output would be:
|
|
{
|
|
'group': set('{tag1}', '{tag2}', '{tag3}),
|
|
'group1': set('{tag2}','{tag3}'),
|
|
}
|
|
|
|
"""
|
|
# Prepare a key set list we can use
|
|
tag_groups = set([str(x) for x in group_tags.keys()])
|
|
|
|
def _expand(tags, ignore=None):
|
|
"""
|
|
Expands based on tag provided and returns a set
|
|
|
|
this also updates the group_tags while it goes
|
|
"""
|
|
|
|
# Prepare ourselves a return set
|
|
results = set()
|
|
ignore = set() if ignore is None else ignore
|
|
|
|
# track groups
|
|
groups = set()
|
|
|
|
for tag in tags:
|
|
if tag in ignore:
|
|
continue
|
|
|
|
# Track our groups
|
|
groups.add(tag)
|
|
|
|
# Store what we know is worth keping
|
|
results |= group_tags[tag] - tag_groups
|
|
|
|
# Get simple tag assignments
|
|
found = group_tags[tag] & tag_groups
|
|
if not found:
|
|
continue
|
|
|
|
for gtag in found:
|
|
if gtag in ignore:
|
|
continue
|
|
|
|
# Go deeper (recursion)
|
|
ignore.add(tag)
|
|
group_tags[gtag] = _expand(set([gtag]), ignore=ignore)
|
|
results |= group_tags[gtag]
|
|
|
|
# Pop ignore
|
|
ignore.remove(tag)
|
|
|
|
return results
|
|
|
|
for tag in tag_groups:
|
|
# Get our tags
|
|
group_tags[tag] |= _expand(set([tag]))
|
|
if not group_tags[tag]:
|
|
ConfigBase.logger.warning(
|
|
'The group {} has no tags assigned to it'.format(tag))
|
|
del group_tags[tag]
|
|
|
|
@staticmethod
|
|
def parse_url(url, verify_host=True):
|
|
"""Parses the URL and returns it broken apart into a dictionary.
|
|
|
|
This is very specific and customized for Apprise.
|
|
|
|
Args:
|
|
url (str): The URL you want to fully parse.
|
|
verify_host (:obj:`bool`, optional): a flag kept with the parsed
|
|
URL which some child classes will later use to verify SSL
|
|
keys (if SSL transactions take place). Unless under very
|
|
specific circumstances, it is strongly recomended that
|
|
you leave this default value set to True.
|
|
|
|
Returns:
|
|
A dictionary is returned containing the URL fully parsed if
|
|
successful, otherwise None is returned.
|
|
"""
|
|
|
|
results = URLBase.parse_url(url, verify_host=verify_host)
|
|
|
|
if not results:
|
|
# We're done; we failed to parse our url
|
|
return results
|
|
|
|
# Allow overriding the default config format
|
|
if 'format' in results['qsd']:
|
|
results['format'] = results['qsd'].get('format')
|
|
if results['format'] not in common.CONFIG_FORMATS:
|
|
URLBase.logger.warning(
|
|
'Unsupported format specified {}'.format(
|
|
results['format']))
|
|
del results['format']
|
|
|
|
# Defines the encoding of the payload
|
|
if 'encoding' in results['qsd']:
|
|
results['encoding'] = results['qsd'].get('encoding')
|
|
|
|
# Our cache value
|
|
if 'cache' in results['qsd']:
|
|
# First try to get it's integer value
|
|
try:
|
|
results['cache'] = int(results['qsd']['cache'])
|
|
|
|
except (ValueError, TypeError):
|
|
# No problem, it just isn't an integer; now treat it as a bool
|
|
# instead:
|
|
results['cache'] = parse_bool(results['qsd']['cache'])
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def detect_config_format(content, **kwargs):
|
|
"""
|
|
Takes the specified content and attempts to detect the format type
|
|
|
|
The function returns the actual format type if detected, otherwise
|
|
it returns None
|
|
"""
|
|
|
|
# Detect Format Logic:
|
|
# - A pound/hashtag (#) is alawys a comment character so we skip over
|
|
# lines matched here.
|
|
# - Detection begins on the first non-comment and non blank line
|
|
# matched.
|
|
# - If we find a string followed by a colon, we know we're dealing
|
|
# with a YAML file.
|
|
# - If we find a string that starts with a URL, or our tag
|
|
# definitions (accepting commas) followed by an equal sign we know
|
|
# we're dealing with a TEXT format.
|
|
|
|
# Define what a valid line should look like
|
|
valid_line_re = re.compile(
|
|
r'^\s*(?P<line>([;#]+(?P<comment>.*))|'
|
|
r'(?P<text>((?P<tag>[ \t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|'
|
|
r'((?P<yaml>[a-z0-9]+):.*))?$', re.I)
|
|
|
|
try:
|
|
# split our content up to read line by line
|
|
content = re.split(r'\r*\n', content)
|
|
|
|
except TypeError:
|
|
# content was not expected string type
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise configuration specified.')
|
|
return None
|
|
|
|
# By default set our return value to None since we don't know
|
|
# what the format is yet
|
|
config_format = None
|
|
|
|
# iterate over each line of the file to attempt to detect it
|
|
# stop the moment a the type has been determined
|
|
for line, entry in enumerate(content, start=1):
|
|
|
|
result = valid_line_re.match(entry)
|
|
if not result:
|
|
# Invalid syntax
|
|
ConfigBase.logger.error(
|
|
'Undetectable Apprise configuration found '
|
|
'based on line {}.'.format(line))
|
|
# Take an early exit
|
|
return None
|
|
|
|
# Attempt to detect configuration
|
|
if result.group('yaml'):
|
|
config_format = common.ConfigFormat.YAML
|
|
ConfigBase.logger.debug(
|
|
'Detected YAML configuration '
|
|
'based on line {}.'.format(line))
|
|
break
|
|
|
|
elif result.group('text'):
|
|
config_format = common.ConfigFormat.TEXT
|
|
ConfigBase.logger.debug(
|
|
'Detected TEXT configuration '
|
|
'based on line {}.'.format(line))
|
|
break
|
|
|
|
# If we reach here, we have a comment entry
|
|
# Adjust default format to TEXT
|
|
config_format = common.ConfigFormat.TEXT
|
|
|
|
return config_format
|
|
|
|
@staticmethod
|
|
def config_parse(content, asset=None, config_format=None, **kwargs):
|
|
"""
|
|
Takes the specified config content and loads it based on the specified
|
|
config_format. If a format isn't specified, then it is auto detected.
|
|
|
|
"""
|
|
|
|
if config_format is None:
|
|
# Detect the format
|
|
config_format = ConfigBase.detect_config_format(content)
|
|
|
|
if not config_format:
|
|
# We couldn't detect configuration
|
|
ConfigBase.logger.error('Could not detect configuration')
|
|
return (list(), list())
|
|
|
|
if config_format not in common.CONFIG_FORMATS:
|
|
# Invalid configuration type specified
|
|
ConfigBase.logger.error(
|
|
'An invalid configuration format ({}) was specified'.format(
|
|
config_format))
|
|
return (list(), list())
|
|
|
|
# Dynamically load our parse_ function based on our config format
|
|
fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format))
|
|
|
|
# Execute our config parse function which always returns a list
|
|
return fn(content=content, asset=asset)
|
|
|
|
@staticmethod
|
|
def config_parse_text(content, asset=None):
|
|
"""
|
|
Parse the specified content as though it were a simple text file only
|
|
containing a list of URLs.
|
|
|
|
Return a tuple that looks like (servers, configs) where:
|
|
- servers contains a list of loaded notification plugins
|
|
- configs contains a list of additional configuration files
|
|
referenced.
|
|
|
|
You may also optionally associate an asset with the notification.
|
|
|
|
The file syntax is:
|
|
|
|
#
|
|
# pound/hashtag allow for line comments
|
|
#
|
|
# One or more tags can be idenified using comma's (,) to separate
|
|
# them.
|
|
<Tag(s)>=<URL>
|
|
|
|
# Or you can use this format (no tags associated)
|
|
<URL>
|
|
|
|
# you can also use the keyword 'include' and identify a
|
|
# configuration location (like this file) which will be included
|
|
# as additional configuration entries when loaded.
|
|
include <ConfigURL>
|
|
|
|
# Assign tag contents to a group identifier
|
|
<Group(s)>=<Tag(s)>
|
|
|
|
"""
|
|
# A list of loaded Notification Services
|
|
servers = list()
|
|
|
|
# A list of additional configuration files referenced using
|
|
# the include keyword
|
|
configs = list()
|
|
|
|
# Track all of the tags we want to assign later on
|
|
group_tags = {}
|
|
|
|
# Track our entries to preload
|
|
preloaded = []
|
|
|
|
# Prepare our Asset Object
|
|
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
|
|
|
|
# Define what a valid line should look like
|
|
valid_line_re = re.compile(
|
|
r'^\s*(?P<line>([;#]+(?P<comment>.*))|'
|
|
r'(\s*(?P<tags>[a-z0-9, \t_-]+)\s*=|=)?\s*'
|
|
r'((?P<url>[a-z0-9]{1,12}://.*)|(?P<assign>[a-z0-9, \t_-]+))|'
|
|
r'include\s+(?P<config>.+))?\s*$', re.I)
|
|
|
|
try:
|
|
# split our content up to read line by line
|
|
content = re.split(r'\r*\n', content)
|
|
|
|
except TypeError:
|
|
# content was not expected string type
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise TEXT based configuration specified.')
|
|
return (list(), list())
|
|
|
|
for line, entry in enumerate(content, start=1):
|
|
result = valid_line_re.match(entry)
|
|
if not result:
|
|
# Invalid syntax
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise TEXT configuration format found '
|
|
'{} on line {}.'.format(entry, line))
|
|
|
|
# Assume this is a file we shouldn't be parsing. It's owner
|
|
# can read the error printed to screen and take action
|
|
# otherwise.
|
|
return (list(), list())
|
|
|
|
# Retrieve our line
|
|
url, assign, config = \
|
|
result.group('url'), \
|
|
result.group('assign'), \
|
|
result.group('config')
|
|
|
|
if not (url or config or assign):
|
|
# Comment/empty line; do nothing
|
|
continue
|
|
|
|
if config:
|
|
# CWE-312 (Secure Logging) Handling
|
|
loggable_url = config if not asset.secure_logging \
|
|
else cwe312_url(config)
|
|
|
|
ConfigBase.logger.debug(
|
|
'Include URL: {}'.format(loggable_url))
|
|
|
|
# Store our include line
|
|
configs.append(config.strip())
|
|
continue
|
|
|
|
# CWE-312 (Secure Logging) Handling
|
|
loggable_url = url if not asset.secure_logging \
|
|
else cwe312_url(url)
|
|
|
|
if assign:
|
|
groups = set(parse_list(result.group('tags'), cast=str))
|
|
if not groups:
|
|
# no tags were assigned
|
|
ConfigBase.logger.warning(
|
|
'Unparseable tag assignment - no group(s) '
|
|
'on line {}'.format(line))
|
|
continue
|
|
|
|
# Get our tags
|
|
tags = set(parse_list(assign, cast=str))
|
|
if not tags:
|
|
# no tags were assigned
|
|
ConfigBase.logger.warning(
|
|
'Unparseable tag assignment - no tag(s) to assign '
|
|
'on line {}'.format(line))
|
|
continue
|
|
|
|
# Update our tag group map
|
|
for tag_group in groups:
|
|
if tag_group not in group_tags:
|
|
group_tags[tag_group] = set()
|
|
|
|
# ensure our tag group is never included in the assignment
|
|
group_tags[tag_group] |= tags - set([tag_group])
|
|
continue
|
|
|
|
# Acquire our url tokens
|
|
results = plugins.url_to_dict(
|
|
url, secure_logging=asset.secure_logging)
|
|
if results is None:
|
|
# Failed to parse the server URL
|
|
ConfigBase.logger.warning(
|
|
'Unparseable URL {} on line {}.'.format(
|
|
loggable_url, line))
|
|
continue
|
|
|
|
# Build a list of tags to associate with the newly added
|
|
# notifications if any were set
|
|
results['tag'] = set(parse_list(result.group('tags'), cast=str))
|
|
|
|
# Set our Asset Object
|
|
results['asset'] = asset
|
|
|
|
# Store our preloaded entries
|
|
preloaded.append({
|
|
'results': results,
|
|
'line': line,
|
|
'loggable_url': loggable_url,
|
|
})
|
|
|
|
#
|
|
# Normalize Tag Groups
|
|
# - Expand Groups of Groups so that they don't exist
|
|
#
|
|
ConfigBase.__normalize_tag_groups(group_tags)
|
|
|
|
#
|
|
# URL Processing
|
|
#
|
|
for entry in preloaded:
|
|
# Point to our results entry for easier reference below
|
|
results = entry['results']
|
|
|
|
#
|
|
# Apply our tag groups if they're defined
|
|
#
|
|
for group, tags in group_tags.items():
|
|
# Detect if anything assigned to this tag also maps back to a
|
|
# group. If so we want to add the group to our list
|
|
if next((True for tag in results['tag']
|
|
if tag in tags), False):
|
|
results['tag'].add(group)
|
|
|
|
try:
|
|
# Attempt to create an instance of our plugin using the
|
|
# parsed URL information
|
|
plugin = common.NOTIFY_SCHEMA_MAP[
|
|
results['schema']](**results)
|
|
|
|
# Create log entry of loaded URL
|
|
ConfigBase.logger.debug(
|
|
'Loaded URL: %s', plugin.url(
|
|
privacy=results['asset'].secure_logging))
|
|
|
|
except Exception as e:
|
|
# the arguments are invalid or can not be used.
|
|
ConfigBase.logger.warning(
|
|
'Could not load URL {} on line {}.'.format(
|
|
entry['loggable_url'], entry['line']))
|
|
ConfigBase.logger.debug('Loading Exception: %s' % str(e))
|
|
continue
|
|
|
|
# if we reach here, we successfully loaded our data
|
|
servers.append(plugin)
|
|
|
|
# Return what was loaded
|
|
return (servers, configs)
|
|
|
|
@staticmethod
|
|
def config_parse_yaml(content, asset=None):
|
|
"""
|
|
Parse the specified content as though it were a yaml file
|
|
specifically formatted for Apprise.
|
|
|
|
Return a tuple that looks like (servers, configs) where:
|
|
- servers contains a list of loaded notification plugins
|
|
- configs contains a list of additional configuration files
|
|
referenced.
|
|
|
|
You may optionally associate an asset with the notification.
|
|
|
|
"""
|
|
|
|
# A list of loaded Notification Services
|
|
servers = list()
|
|
|
|
# A list of additional configuration files referenced using
|
|
# the include keyword
|
|
configs = list()
|
|
|
|
# Group Assignments
|
|
group_tags = {}
|
|
|
|
# Track our entries to preload
|
|
preloaded = []
|
|
|
|
try:
|
|
# Load our data (safely)
|
|
result = yaml.load(content, Loader=yaml.SafeLoader)
|
|
|
|
except (AttributeError,
|
|
yaml.parser.ParserError,
|
|
yaml.error.MarkedYAMLError) as e:
|
|
# Invalid content
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise YAML data specified.')
|
|
ConfigBase.logger.debug(
|
|
'YAML Exception:{}{}'.format(os.linesep, e))
|
|
return (list(), list())
|
|
|
|
if not isinstance(result, dict):
|
|
# Invalid content
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise YAML based configuration specified.')
|
|
return (list(), list())
|
|
|
|
# YAML Version
|
|
version = result.get('version', 1)
|
|
if version != 1:
|
|
# Invalid syntax
|
|
ConfigBase.logger.error(
|
|
'Invalid Apprise YAML version specified {}.'.format(version))
|
|
return (list(), list())
|
|
|
|
#
|
|
# global asset object
|
|
#
|
|
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
|
|
tokens = result.get('asset', None)
|
|
if tokens and isinstance(tokens, dict):
|
|
for k, v in tokens.items():
|
|
|
|
if k.startswith('_') or k.endswith('_'):
|
|
# Entries are considered reserved if they start or end
|
|
# with an underscore
|
|
ConfigBase.logger.warning(
|
|
'Ignored asset key "{}".'.format(k))
|
|
continue
|
|
|
|
if not (hasattr(asset, k) and
|
|
isinstance(getattr(asset, k),
|
|
(bool, str))):
|
|
|
|
# We can't set a function or non-string set value
|
|
ConfigBase.logger.warning(
|
|
'Invalid asset key "{}".'.format(k))
|
|
continue
|
|
|
|
if v is None:
|
|
# Convert to an empty string
|
|
v = ''
|
|
|
|
if (isinstance(v, (bool, str))
|
|
and isinstance(getattr(asset, k), bool)):
|
|
|
|
# If the object in the Asset is a boolean, then
|
|
# we want to convert the specified string to
|
|
# match that.
|
|
setattr(asset, k, parse_bool(v))
|
|
|
|
elif isinstance(v, str):
|
|
# Set our asset object with the new value
|
|
setattr(asset, k, v.strip())
|
|
|
|
else:
|
|
# we must set strings with a string
|
|
ConfigBase.logger.warning(
|
|
'Invalid asset value to "{}".'.format(k))
|
|
continue
|
|
#
|
|
# global tag root directive
|
|
#
|
|
global_tags = set()
|
|
|
|
tags = result.get('tag', None)
|
|
if tags and isinstance(tags, (list, tuple, str)):
|
|
# Store any preset tags
|
|
global_tags = set(parse_list(tags, cast=str))
|
|
|
|
#
|
|
# groups root directive
|
|
#
|
|
groups = result.get('groups', None)
|
|
if not isinstance(groups, (list, tuple)):
|
|
# Not a problem; we simply have no group entry
|
|
groups = list()
|
|
|
|
# Iterate over each group defined and store it
|
|
for no, entry in enumerate(groups):
|
|
if not isinstance(entry, dict):
|
|
ConfigBase.logger.warning(
|
|
'No assignment for group {}, entry #{}'.format(
|
|
entry, no + 1))
|
|
continue
|
|
|
|
for _groups, tags in entry.items():
|
|
for group in parse_list(_groups, cast=str):
|
|
if isinstance(tags, (list, tuple)):
|
|
_tags = set()
|
|
for e in tags:
|
|
if isinstance(e, dict):
|
|
_tags |= set(e.keys())
|
|
else:
|
|
_tags |= set(parse_list(e, cast=str))
|
|
|
|
# Final assignment
|
|
tags = _tags
|
|
|
|
else:
|
|
tags = set(parse_list(tags, cast=str))
|
|
|
|
if group not in group_tags:
|
|
group_tags[group] = tags
|
|
|
|
else:
|
|
group_tags[group] |= tags
|
|
|
|
#
|
|
# include root directive
|
|
#
|
|
includes = result.get('include', None)
|
|
if isinstance(includes, str):
|
|
# Support a single inline string or multiple ones separated by a
|
|
# comma and/or space
|
|
includes = parse_urls(includes)
|
|
|
|
elif not isinstance(includes, (list, tuple)):
|
|
# Not a problem; we simply have no includes
|
|
includes = list()
|
|
|
|
# Iterate over each config URL
|
|
for no, url in enumerate(includes):
|
|
|
|
if isinstance(url, str):
|
|
# Support a single inline string or multiple ones separated by
|
|
# a comma and/or space
|
|
configs.extend(parse_urls(url))
|
|
|
|
elif isinstance(url, dict):
|
|
# Store the url and ignore arguments associated
|
|
configs.extend(u for u in url.keys())
|
|
|
|
#
|
|
# urls root directive
|
|
#
|
|
urls = result.get('urls', None)
|
|
if not isinstance(urls, (list, tuple)):
|
|
# Not a problem; we simply have no urls
|
|
urls = list()
|
|
|
|
# Iterate over each URL
|
|
for no, url in enumerate(urls):
|
|
|
|
# Our results object is what we use to instantiate our object if
|
|
# we can. Reset it to None on each iteration
|
|
results = list()
|
|
|
|
# CWE-312 (Secure Logging) Handling
|
|
loggable_url = url if not asset.secure_logging \
|
|
else cwe312_url(url)
|
|
|
|
if isinstance(url, str):
|
|
# We're just a simple URL string...
|
|
schema = GET_SCHEMA_RE.match(url)
|
|
if schema is None:
|
|
# Log invalid entries so that maintainer of config
|
|
# config file at least has something to take action
|
|
# with.
|
|
ConfigBase.logger.warning(
|
|
'Invalid URL {}, entry #{}'.format(
|
|
loggable_url, no + 1))
|
|
continue
|
|
|
|
# We found a valid schema worthy of tracking; store it's
|
|
# details:
|
|
_results = plugins.url_to_dict(
|
|
url, secure_logging=asset.secure_logging)
|
|
if _results is None:
|
|
ConfigBase.logger.warning(
|
|
'Unparseable URL {}, entry #{}'.format(
|
|
loggable_url, no + 1))
|
|
continue
|
|
|
|
# add our results to our global set
|
|
results.append(_results)
|
|
|
|
elif isinstance(url, dict):
|
|
# We are a url string with additional unescaped options. In
|
|
# this case we want to iterate over all of our options so we
|
|
# can at least tell the end user what entries were ignored
|
|
# due to errors
|
|
|
|
it = iter(url.items())
|
|
|
|
# Track the URL to-load
|
|
_url = None
|
|
|
|
# Track last acquired schema
|
|
schema = None
|
|
for key, tokens in it:
|
|
# Test our schema
|
|
_schema = GET_SCHEMA_RE.match(key)
|
|
if _schema is None:
|
|
# Log invalid entries so that maintainer of config
|
|
# config file at least has something to take action
|
|
# with.
|
|
ConfigBase.logger.warning(
|
|
'Ignored entry {} found under urls, entry #{}'
|
|
.format(key, no + 1))
|
|
continue
|
|
|
|
# Store our schema
|
|
schema = _schema.group('schema').lower()
|
|
|
|
# Store our URL and Schema Regex
|
|
_url = key
|
|
|
|
if _url is None:
|
|
# the loop above failed to match anything
|
|
ConfigBase.logger.warning(
|
|
'Unsupported URL, entry #{}'.format(no + 1))
|
|
continue
|
|
|
|
_results = plugins.url_to_dict(
|
|
_url, secure_logging=asset.secure_logging)
|
|
if _results is None:
|
|
# Setup dictionary
|
|
_results = {
|
|
# Minimum requirements
|
|
'schema': schema,
|
|
}
|
|
|
|
if isinstance(tokens, (list, tuple, set)):
|
|
# populate and/or override any results populated by
|
|
# parse_url()
|
|
for entries in tokens:
|
|
# Copy ourselves a template of our parsed URL as a base
|
|
# to work with
|
|
r = _results.copy()
|
|
|
|
# We are a url string with additional unescaped options
|
|
if isinstance(entries, dict):
|
|
_url, tokens = next(iter(url.items()))
|
|
|
|
# Tags you just can't over-ride
|
|
if 'schema' in entries:
|
|
del entries['schema']
|
|
|
|
# support our special tokens (if they're present)
|
|
if schema in common.NOTIFY_SCHEMA_MAP:
|
|
entries = ConfigBase._special_token_handler(
|
|
schema, entries)
|
|
|
|
# Extend our dictionary with our new entries
|
|
r.update(entries)
|
|
|
|
# add our results to our global set
|
|
results.append(r)
|
|
|
|
elif isinstance(tokens, dict):
|
|
# support our special tokens (if they're present)
|
|
if schema in common.NOTIFY_SCHEMA_MAP:
|
|
tokens = ConfigBase._special_token_handler(
|
|
schema, tokens)
|
|
|
|
# Copy ourselves a template of our parsed URL as a base to
|
|
# work with
|
|
r = _results.copy()
|
|
|
|
# add our result set
|
|
r.update(tokens)
|
|
|
|
# add our results to our global set
|
|
results.append(r)
|
|
|
|
else:
|
|
# add our results to our global set
|
|
results.append(_results)
|
|
|
|
else:
|
|
# Unsupported
|
|
ConfigBase.logger.warning(
|
|
'Unsupported Apprise YAML entry #{}'.format(no + 1))
|
|
continue
|
|
|
|
# Track our entries
|
|
entry = 0
|
|
|
|
while len(results):
|
|
# Increment our entry count
|
|
entry += 1
|
|
|
|
# Grab our first item
|
|
_results = results.pop(0)
|
|
|
|
if _results['schema'] not in common.NOTIFY_SCHEMA_MAP:
|
|
# the arguments are invalid or can not be used.
|
|
ConfigBase.logger.warning(
|
|
'An invalid Apprise schema ({}) in YAML configuration '
|
|
'entry #{}, item #{}'
|
|
.format(_results['schema'], no + 1, entry))
|
|
continue
|
|
|
|
# tag is a special keyword that is managed by Apprise object.
|
|
# The below ensures our tags are set correctly
|
|
if 'tag' in _results:
|
|
# Tidy our list up
|
|
_results['tag'] = set(
|
|
parse_list(_results['tag'], cast=str)) | global_tags
|
|
|
|
else:
|
|
# Just use the global settings
|
|
_results['tag'] = global_tags
|
|
|
|
for key in list(_results.keys()):
|
|
# Strip out any tokens we know that we can't accept and
|
|
# warn the user
|
|
match = VALID_TOKEN.match(key)
|
|
if not match:
|
|
ConfigBase.logger.warning(
|
|
'Ignoring invalid token ({}) found in YAML '
|
|
'configuration entry #{}, item #{}'
|
|
.format(key, no + 1, entry))
|
|
del _results[key]
|
|
|
|
ConfigBase.logger.trace(
|
|
'URL #{}: {} unpacked as:{}{}'
|
|
.format(no + 1, url, os.linesep, os.linesep.join(
|
|
['{}="{}"'.format(k, a)
|
|
for k, a in _results.items()])))
|
|
|
|
# Prepare our Asset Object
|
|
_results['asset'] = asset
|
|
|
|
# Store our preloaded entries
|
|
preloaded.append({
|
|
'results': _results,
|
|
'entry': no + 1,
|
|
'item': entry,
|
|
})
|
|
|
|
#
|
|
# Normalize Tag Groups
|
|
# - Expand Groups of Groups so that they don't exist
|
|
#
|
|
ConfigBase.__normalize_tag_groups(group_tags)
|
|
|
|
#
|
|
# URL Processing
|
|
#
|
|
for entry in preloaded:
|
|
# Point to our results entry for easier reference below
|
|
results = entry['results']
|
|
|
|
#
|
|
# Apply our tag groups if they're defined
|
|
#
|
|
for group, tags in group_tags.items():
|
|
# Detect if anything assigned to this tag also maps back to a
|
|
# group. If so we want to add the group to our list
|
|
if next((True for tag in results['tag']
|
|
if tag in tags), False):
|
|
results['tag'].add(group)
|
|
|
|
# Now we generate our plugin
|
|
try:
|
|
# Attempt to create an instance of our plugin using the
|
|
# parsed URL information
|
|
plugin = common.\
|
|
NOTIFY_SCHEMA_MAP[results['schema']](**results)
|
|
|
|
# Create log entry of loaded URL
|
|
ConfigBase.logger.debug(
|
|
'Loaded URL: %s', plugin.url(
|
|
privacy=results['asset'].secure_logging))
|
|
|
|
except Exception as e:
|
|
# the arguments are invalid or can not be used.
|
|
ConfigBase.logger.warning(
|
|
'Could not load Apprise YAML configuration '
|
|
'entry #{}, item #{}'
|
|
.format(entry['entry'], entry['item']))
|
|
ConfigBase.logger.debug('Loading Exception: %s' % str(e))
|
|
continue
|
|
|
|
# if we reach here, we successfully loaded our data
|
|
servers.append(plugin)
|
|
|
|
return (servers, configs)
|
|
|
|
def pop(self, index=-1):
|
|
"""
|
|
Removes an indexed Notification Service from the stack and returns it.
|
|
|
|
By default, the last element of the list is removed.
|
|
"""
|
|
|
|
if not isinstance(self._cached_servers, list):
|
|
# Generate ourselves a list of content we can pull from
|
|
self.servers()
|
|
|
|
# Pop the element off of the stack
|
|
return self._cached_servers.pop(index)
|
|
|
|
@staticmethod
|
|
def _special_token_handler(schema, tokens):
|
|
"""
|
|
This function takes a list of tokens and updates them to no longer
|
|
include any special tokens such as +,-, and :
|
|
|
|
- schema must be a valid schema of a supported plugin type
|
|
- tokens must be a dictionary containing the yaml entries parsed.
|
|
|
|
The idea here is we can post process a set of tokens provided in
|
|
a YAML file where the user provided some of the special keywords.
|
|
|
|
We effectivley look up what these keywords map to their appropriate
|
|
value they're expected
|
|
"""
|
|
# Create a copy of our dictionary
|
|
tokens = tokens.copy()
|
|
|
|
for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\
|
|
.template_kwargs.items():
|
|
|
|
# Determine our prefix:
|
|
prefix = meta.get('prefix', '+')
|
|
|
|
# Detect any matches
|
|
matches = \
|
|
{k[1:]: str(v) for k, v in tokens.items()
|
|
if k.startswith(prefix)}
|
|
|
|
if not matches:
|
|
# we're done with this entry
|
|
continue
|
|
|
|
if not isinstance(tokens.get(kw), dict):
|
|
# Invalid; correct it
|
|
tokens[kw] = dict()
|
|
|
|
# strip out processed tokens
|
|
tokens = {k: v for k, v in tokens.items()
|
|
if not k.startswith(prefix)}
|
|
|
|
# Update our entries
|
|
tokens[kw].update(matches)
|
|
|
|
# Now map our tokens accordingly to the class templates defined by
|
|
# each service.
|
|
#
|
|
# This is specifically used for YAML file parsing. It allows a user to
|
|
# define an entry such as:
|
|
#
|
|
# urls:
|
|
# - mailto://user:pass@domain:
|
|
# - to: user1@hotmail.com
|
|
# - to: user2@hotmail.com
|
|
#
|
|
# Under the hood, the NotifyEmail() class does not parse the `to`
|
|
# argument. It's contents needs to be mapped to `targets`. This is
|
|
# defined in the class via the `template_args` and template_tokens`
|
|
# section.
|
|
#
|
|
# This function here allows these mappings to take place within the
|
|
# YAML file as independant arguments.
|
|
class_templates = \
|
|
plugins.details(common.NOTIFY_SCHEMA_MAP[schema])
|
|
|
|
for key in list(tokens.keys()):
|
|
|
|
if key not in class_templates['args']:
|
|
# No need to handle non-arg entries
|
|
continue
|
|
|
|
# get our `map_to` and/or 'alias_of' value (if it exists)
|
|
map_to = class_templates['args'][key].get(
|
|
'alias_of', class_templates['args'][key].get('map_to', ''))
|
|
|
|
if map_to == key:
|
|
# We're already good as we are now
|
|
continue
|
|
|
|
if map_to in class_templates['tokens']:
|
|
meta = class_templates['tokens'][map_to]
|
|
|
|
else:
|
|
meta = class_templates['args'].get(
|
|
map_to, class_templates['args'][key])
|
|
|
|
# Perform a translation/mapping if our code reaches here
|
|
value = tokens[key]
|
|
del tokens[key]
|
|
|
|
# Detect if we're dealign with a list or not
|
|
is_list = re.search(
|
|
r'^list:.*',
|
|
meta.get('type'),
|
|
re.IGNORECASE)
|
|
|
|
if map_to not in tokens:
|
|
tokens[map_to] = [] if is_list \
|
|
else meta.get('default')
|
|
|
|
elif is_list and not isinstance(tokens.get(map_to), list):
|
|
# Convert ourselves to a list if we aren't already
|
|
tokens[map_to] = [tokens[map_to]]
|
|
|
|
# Type Conversion
|
|
if re.search(
|
|
r'^(choice:)?string',
|
|
meta.get('type'),
|
|
re.IGNORECASE) \
|
|
and not isinstance(value, str):
|
|
|
|
# Ensure our format is as expected
|
|
value = str(value)
|
|
|
|
# Apply any further translations if required (absolute map)
|
|
# This is the case when an arg maps to a token which further
|
|
# maps to a different function arg on the class constructor
|
|
abs_map = meta.get('map_to', map_to)
|
|
|
|
# Set our token as how it was provided by the configuration
|
|
if isinstance(tokens.get(map_to), list):
|
|
tokens[abs_map].append(value)
|
|
|
|
else:
|
|
tokens[abs_map] = value
|
|
|
|
# Return our tokens
|
|
return tokens
|
|
|
|
def __getitem__(self, index):
|
|
"""
|
|
Returns the indexed server entry associated with the loaded
|
|
notification servers
|
|
"""
|
|
if not isinstance(self._cached_servers, list):
|
|
# Generate ourselves a list of content we can pull from
|
|
self.servers()
|
|
|
|
return self._cached_servers[index]
|
|
|
|
def __iter__(self):
|
|
"""
|
|
Returns an iterator to our server list
|
|
"""
|
|
if not isinstance(self._cached_servers, list):
|
|
# Generate ourselves a list of content we can pull from
|
|
self.servers()
|
|
|
|
return iter(self._cached_servers)
|
|
|
|
def __len__(self):
|
|
"""
|
|
Returns the total number of servers loaded
|
|
"""
|
|
if not isinstance(self._cached_servers, list):
|
|
# Generate ourselves a list of content we can pull from
|
|
self.servers()
|
|
|
|
return len(self._cached_servers)
|
|
|
|
def __bool__(self):
|
|
"""
|
|
Allows the Apprise object to be wrapped in an 'if statement'.
|
|
True is returned if our content was downloaded correctly.
|
|
"""
|
|
if not isinstance(self._cached_servers, list):
|
|
# Generate ourselves a list of content we can pull from
|
|
self.servers()
|
|
|
|
return True if self._cached_servers else False
|