Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 238 additions & 13 deletions apprise/plugins/nextcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,37 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

from itertools import chain
import re

import requests

from ..common import NotifyType
from ..common import NotifyType, PersistentStoreMode
from ..locale import gettext_lazy as _
from ..url import PrivacyMode
from ..utils.parse import parse_list
from .base import NotifyBase

# Is Group Detection
IS_GROUP = re.compile(
r"^\s*((#|%23)(?P<group>[A-Za-z0-9_-]+)|"
r"((#|%23)?(?P<all>all|everyone|[*])))\s*$",
re.I,
)


class NotifyNextcloud(NotifyBase):
"""A wrapper for Nextcloud Notifications."""
"""A wrapper for Nextcloud Notifications.

Targets can be individual users, groups, or everyone:
- user: specify one or more usernames as path segments
- group: prefix with a hash (e.g., ``#DevTeam``)
- everyone: use ``all`` (aliases: ``everyone``, ``*``)

Group and everyone expansion uses Nextcloud's OCS provisioning API and
requires appropriate permissions (typically an admin account) and the
provisioning API enabled on the server.
"""

# The default descriptive name associated with the Notification
service_name = "Nextcloud"
Expand All @@ -58,6 +78,16 @@ class NotifyNextcloud(NotifyBase):
# Defines the maximum allowable characters per message.
body_maxlen = 4000

# Our default is to no not use persistent storage beyond in-memory
# reference
storage_mode = PersistentStoreMode.AUTO

# Defines how long we cache our discovery for
group_discovery_cache_length_sec = 86400

# unique identifier to cache the 'all' group category
all_group_id = "all"

# Define object templates
templates = (
"{schema}://{host}/{targets}",
Expand Down Expand Up @@ -95,6 +125,12 @@ class NotifyNextcloud(NotifyBase):
"type": "string",
"map_to": "targets",
},
"target_group": {
"name": _("Target Group"),
"type": "string",
"map_to": "targets",
"prefix": "#",
},
"targets": {
"name": _("Targets"),
"type": "list:string",
Expand Down Expand Up @@ -146,7 +182,26 @@ def __init__(
super().__init__(**kwargs)

# Store our targets
self.targets = parse_list(targets)
self.targets = []
self.groups = set()

for target in parse_list(targets):
results = IS_GROUP.match(target)
if results:
group_id = (
self.all_group_id
if results.group("all") else results.group("group"))
if group_id not in self.groups:
self.groups.add(group_id)
self.logger.info("Added Nextcloud group '%s'", group_id)

else:
self.logger.warning(
"Ignored duplicate Nextcloud group '%s'", group_id)
continue

# Store our target
self.targets.append(target)

self.version = self.template_args["version"]["default"]
if version is not None:
Expand Down Expand Up @@ -176,11 +231,6 @@ def __init__(
def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
"""Perform Nextcloud Notification."""

if len(self.targets) == 0:
# There were no services to notify
self.logger.warning("There were no Nextcloud targets to notify.")
return False

# Prepare our Header
headers = {
"User-Agent": self.app_id,
Expand All @@ -193,11 +243,25 @@ def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
# error tracking (used for function return)
has_error = False

# Create a copy of the targets list
targets = list(self.targets)
while len(targets):
target = targets.pop(0)
# Create a copy of our targets
targets = set(self.targets)

if self.groups:
# Append our group lookup
try:
targets |= self.grouped_targets()

except Exception as e: # pragma: no cover - defensive guard
self.logger.warning("Failed to resolve Nextcloud targets")
self.logger.debug(f"Resolution Exception: {e!s}")
return False

if not targets:
# There were no services to notify
self.logger.warning("There were no Nextcloud targets to notify.")
return False

for target in targets:
# Prepare our Payload
payload = {
"shortMessage": title if title else self.app_desc,
Expand Down Expand Up @@ -297,6 +361,155 @@ def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):

return not has_error

def grouped_targets(self):
"""Resolve our groups if defined; leverage our cache if present
for speed

"""

targets = set()

# Prepare base URL fragments
scheme = "https" if self.secure else "http"
host_port = (
self.host
if not isinstance(self.port, int)
else f"{self.host}:{self.port}"
)
base = f"{scheme}://{host_port}"
if self.url_prefix:
base = f"{base}/{self.url_prefix}"

# common headers for OCS queries
headers = {
"User-Agent": self.app_id,
"OCS-APIREQUEST": "true",
# Prefer JSON
"Accept": "application/json",
}

auth = (self.user, self.password) if self.user else None

def _parse_users_response(resp):
# Parse JSON only
try:
j = resp.json()
data = j.get("ocs", {}).get("data", {})
users = data.get("users")
if isinstance(users, list):
return {
str(u).strip() for u in users if str(u).strip()}

except Exception:
return set()

return set()

def list_group_users(group):
"""
Lists users associated with a provided group
"""

# Check our cache
targets = self.store.get(group)
if targets is not None:
# Returned cached value
return set(targets)

q = NotifyNextcloud.quote(group)
url = f"{base}/ocs/v1.php/cloud/groups/{q}?format=json"
self.logger.debug("Nextcloud OCS GET: %s", url)
self.throttle()
try:
r = requests.get(
url,
headers=headers,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
except requests.RequestException as e:
self.logger.debug(f"OCS GET Exception: {e!s}")
return set()

if r.status_code != requests.codes.ok:
self.logger.debug(
"OCS GET non-200 at %s: %d", url, r.status_code
)
return set()

users = _parse_users_response(r)
if not users:
self.logger.warning(
"Failed to list users for Nextcloud group '%s'", group
)

self.store.set(
group, users, expires=self.group_discovery_cache_length_sec)
return users

def list_all_users():
"""
List all users and return a set
"""
# Check our cache
targets = self.store.get(self.all_group_id)
if targets is not None:
return set(targets)

url = f"{base}/ocs/v1.php/cloud/users?format=json"
self.logger.debug("Nextcloud OCS GET: %s", url)
self.throttle()
try:
r = requests.get(
url,
headers=headers,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
except requests.RequestException as e:
self.logger.debug(f"OCS GET Exception: {e!s}")
return set()

if r.status_code != requests.codes.ok:
status_str = NotifyNextcloud.http_response_code_lookup(
r.status_code
)

self.logger.warning(
"Failed to list Nextcloud v{} users:"
"{}{}error={}.".format(
self.version,
status_str,
", " if status_str else "",
r.status_code,
)
)

self.logger.debug(f"Response Details:\r\n{r.content}")
return set()

users = _parse_users_response(r)
if not users:
self.logger.warning("Failed to list all Nextcloud users")

else:
self.store.set(
self.all_group_id,
users, expires=self.group_discovery_cache_length_sec)
return users

for group in self.groups:
if group == self.all_group_id:
targets |= list_all_users()

else:
# regular username
targets |= list_group_users(group)

return targets

@property
def url_identifier(self):
"""Returns all of the identifiers that make this URL unique from
Expand All @@ -310,6 +523,7 @@ def url_identifier(self):
self.password,
self.host,
self.port,
self.url_prefix,
)

def url(self, privacy=False, *args, **kwargs):
Expand Down Expand Up @@ -341,6 +555,8 @@ def url(self, privacy=False, *args, **kwargs):
user=NotifyNextcloud.quote(self.user, safe=""),
)

group_prefix = self.template_tokens["target_group"]["prefix"]

default_port = 443 if self.secure else 80
return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format(
schema=self.secure_protocol if self.secure else self.protocol,
Expand All @@ -353,7 +569,16 @@ def url(self, privacy=False, *args, **kwargs):
if self.port is None or self.port == default_port
else f":{self.port}"
),
targets="/".join([NotifyNextcloud.quote(x) for x in self.targets]),
targets="/".join([
NotifyNextcloud.quote(x, safe=group_prefix)
for x in chain(
# Groups are prefixed with a pound/hashtag symbol
[f"{group_prefix}{x}" for x in self.groups],
# Users
self.targets,
)
]),

params=NotifyNextcloud.urlencode(params),
)

Expand Down
Loading
Loading