Mercurial > libervia-web
changeset 1518:eb00d593801d
refactoring: rename `libervia` to `libervia.web` + update imports following backend changes
line wrap: on
line diff
--- a/doc/conf.py Thu Jun 01 21:42:02 2023 +0200 +++ b/doc/conf.py Fri Jun 02 16:49:28 2023 +0200 @@ -21,12 +21,12 @@ # -- Project information ----------------------------------------------------- -project = u'Libervia (Salut à Toi)' -copyright = u'2019-2021 Jérôme Poisson' +project = u'Libervia Web' +copyright = u'2019-2023 Jérôme Poisson' author = u'Jérôme Poisson' doc_dir = os.path.dirname(os.path.abspath(__file__)) -version_path = os.path.join(doc_dir, '../libervia/VERSION') +version_path = os.path.join(doc_dir, '../libervia/web/VERSION') with open(version_path) as f: version_full = f.read()
--- a/libervia/VERSION Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -0.9.0D
--- a/libervia/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ -import os.path - -version_file = os.path.join(os.path.dirname(__file__), "VERSION") -with open(version_file) as f: - __version__ = f.read().strip()
--- a/libervia/common/constants.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 - - -# Libervia: a SAT frontend -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from sat_frontends.quick_frontend import constants -import os.path - - -class Const(constants.Const): - - # XXX: we don't want to use the APP_VERSION inherited from sat.core.constants version - # as we use this version to check that there is not a mismatch with backend - APP_VERSION = "0.9.0D" # Please add 'D' at the end for dev versions - LIBERVIA_MAIN_PAGE = "libervia.html" - LIBERVIA_PAGE_START = "/login" - - # REGISTRATION - # XXX: for now libervia forces the creation to lower case - # XXX: Regex patterns must be compatible with both Python and JS - REG_LOGIN_RE = r"^[a-z0-9_-]+$" - REG_EMAIL_RE = r"^.+@.+\..+" - PASSWORD_MIN_LENGTH = 6 - - # HTTP REQUEST RESULT VALUES - PROFILE_AUTH_ERROR = "PROFILE AUTH ERROR" - XMPP_AUTH_ERROR = "XMPP AUTH ERROR" - ALREADY_WAITING = "ALREADY WAITING" - SESSION_ACTIVE = "SESSION ACTIVE" - NOT_CONNECTED = "NOT CONNECTED" - PROFILE_LOGGED = "LOGGED" - PROFILE_LOGGED_EXT_JID = "LOGGED (REGISTERED WITH EXTERNAL JID)" - ALREADY_EXISTS = "ALREADY EXISTS" - INVALID_CERTIFICATE = "INVALID CERTIFICATE" - REGISTRATION_SUCCEED = "REGISTRATION" - INTERNAL_ERROR = "INTERNAL ERROR" - INVALID_INPUT = "INVALID INPUT" - BAD_REQUEST = "BAD REQUEST" - NO_REPLY = "NO REPLY" - NOT_ALLOWED = "NOT ALLOWED" - UPLOAD_OK = "UPLOAD OK" - UPLOAD_KO = "UPLOAD KO" - - # directories - MEDIA_DIR = "media/" - CACHE_DIR = "cache" - - # avatars - DEFAULT_AVATAR_FILE = "default_avatar.png" - DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE) - EMPTY_AVATAR_FILE = "empty_avatar" - EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE) - - # blog - MAM_FILTER_CATEGORY = "http://salut-a-toi.org/protocols/mam_filter_category"
--- a/libervia/pages/_bridge/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 - -import json -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat_frontends.bridge.bridge_frontend import BridgeException -from libervia.server.constants import Const as C - - -log = getLogger(__name__) -"""access to restricted bridge""" - -name = "bridge" -on_data_post = "continue" - -# bridge method allowed when no profile is connected -NO_SESSION_ALLOWED = ("contacts_get", "identities_base_get", "identities_get") - - -def parse_url(self, request): - self.get_path_args(request, ["method_name"], min_args=1) - - -async def render(self, request): - if request.method != b'POST': - log.warning(f"Bad method used with _bridge endpoint: {request.method.decode()}") - return self.page_error(request, C.HTTP_BAD_REQUEST) - data = self.get_r_data(request) - profile = self.get_profile(request) - self.check_csrf(request) - method_name = data["method_name"] - if profile is None: - if method_name in NO_SESSION_ALLOWED: - # this method is allowed, we use the service profile - profile = C.SERVICE_PROFILE - else: - log.warning("_bridge endpoint accessed without authorisation") - return self.page_error(request, C.HTTP_UNAUTHORIZED) - method_data = json.load(request.content) - try: - bridge_method = getattr(self.host.restricted_bridge, method_name) - except AttributeError: - log.warning(_( - "{profile!r} is trying to access a bridge method not implemented in " - "RestrictedBridge: {method_name}").format( - profile=profile, method_name=method_name)) - return self.page_error(request, C.HTTP_BAD_REQUEST) - - try: - args, kwargs = method_data['args'], method_data['kwargs'] - except KeyError: - log.warning(_( - "{profile!r} has sent a badly formatted method call: {method_data}" - ).format(profile=profile, method_data=method_data)) - return self.page_error(request, C.HTTP_BAD_REQUEST) - - if "profile" in kwargs or "profile_key" in kwargs: - log.warning(_( - '"profile" key should not be in method kwargs, hack attempt? ' - "profile={profile}, method_data={method_data}" - ).format(profile=profile, method_data=method_data)) - return self.page_error(request, C.HTTP_BAD_REQUEST) - - try: - ret = await bridge_method(*args, **kwargs, profile=profile) - except BridgeException as e: - request.setResponseCode(C.HTTP_PROXY_ERROR) - ret = { - "fullname": e.fullname, - "message": e.message, - "condition": e.condition, - "module": e.module, - "classname": e.classname, - } - return json.dumps(ret)
--- a/libervia/pages/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ -import bridge - - -# we create a bridge instance to receive signals -bridge.Bridge()
--- a/libervia/pages/_browser/alt_media_player.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 - -"""This module implement an alternative media player - -If browser can't play natively some libre video/audio formats, ogv.js will be used, -otherwise the native player will be used. - -This player uses its own controls, this allow better tuning/event handling notably with -slideshow. -""" - -from browser import document, timer, html - - -NO_PAGINATION = "NO_PAGINATION" -NO_SCROLLBAR = "NO_SCROLLBAR" - - -class MediaPlayer: - TIMER_MODES = ("timer", "remaining") - # will be set to False if browser can't play natively webm or ogv - native = True - # will be set to True when template and modules will be imported - imports_done = False - - def __init__( - self, - sources, - to_rpl_vid_elt=None, - poster=None, - reduce_click_area=False - ): - """ - @param sources: list of paths to media - only the first one is used at the moment - @param to_rpl_vid_elt: video element to replace - if None, nothing is replaced and element must be inserted manually - @param reduce_click_area: when True, only center of the element will react to - click. Useful when used in slideshow, as click on border is used to - show/hide slide controls - """ - self.do_imports() - - self.reduce_click_area = reduce_click_area - - self.media_player_elt = media_player_elt = media_player_tpl.get_elt() - self.player = player = self._create_player(sources, poster) - if to_rpl_vid_elt is not None: - to_rpl_vid_elt.parentNode.replaceChild(media_player_elt, to_rpl_vid_elt) - overlay_play_elt = self.media_player_elt.select_one(".media_overlay_play") - overlay_play_elt.bind("click", self.on_play_click) - self.progress_elt = media_player_elt.select_one("progress") - self.progress_elt.bind("click", self.on_progress_click) - self.timer_elt = media_player_elt.select_one(".timer") - self.timer_mode = "timer" - - self.controls_elt = media_player_elt.select_one(".media_controls") - # we devnull 2 following events to avoid accidental side effect - # this is notably useful in slideshow to avoid changing the slide when - # the user misses slightly a button - self.controls_elt.bind("mousedown", self._devnull) - self.controls_elt.bind("click", self._devnull) - - player_wrapper_elt = media_player_elt.select_one(".media_elt") - player.preload = "none" - player.src = sources[0] - player_wrapper_elt <= player - self.hide_controls_timer = None - - # we capture mousedown to avoid side effect on slideshow - player_wrapper_elt.addEventListener("mousedown", self._devnull) - player_wrapper_elt.addEventListener("click", self.on_player_click) - - # buttons - for handler in ("play", "change_timer_mode", "change_volume", "fullscreen"): - for elt in media_player_elt.select(f".click_to_{handler}"): - elt.bind("click", getattr(self, f"on_{handler}_click")) - # events - # FIXME: progress is not implemented in OGV.js, update when available - for event in ("play", "pause", "timeupdate", "ended", "volumechange"): - player.bind(event, getattr(self, f"on_{event}")) - - @property - def elt(self): - return self.media_player_elt - - def _create_player(self, sources, poster): - """Create player element, using native one when possible""" - player = None - if not self.native: - source = sources[0] - ext = self.get_source_ext(source) - if ext is None: - print( - f"no extension found for {source}, using native player" - ) - elif ext in self.cant_play_ext_list: - print(f"OGV player user for {source}") - player = self.ogv.OGVPlayer.new() - # OGCPlayer has non standard "poster" property - player.poster = poster - if player is None: - player = html.VIDEO(poster=poster) - return player - - def reset(self): - """Put back media player in intial state - - media will be stopped, time will be set to beginning, overlay will be put back - """ - print("resetting media player") - self.player.pause() - self.player.currentTime = 0 - self.media_player_elt.classList.remove("in_use") - - def _devnull(self, evt): - # stop an event - evt.preventDefault() - evt.stopPropagation() - - def on_player_click(self, evt): - if self.reduce_click_area: - bounding_rect = self.media_player_elt.getBoundingClientRect() - margin_x = margin_y = 200 - if ((evt.clientX - bounding_rect.left < margin_x - or bounding_rect.right - evt.clientX < margin_x - or evt.clientY - bounding_rect.top < margin_y - or bounding_rect.bottom - evt.clientY < margin_y - )): - # click is not in the center, we don't handle it and let the event - # propagate - return - self.on_play_click(evt) - - def on_play_click(self, evt): - evt.preventDefault() - evt.stopPropagation() - self.media_player_elt.classList.add("in_use") - if self.player.paused: - print("playing") - self.player.play() - else: - self.player.pause() - print("paused") - - def on_change_timer_mode_click(self, evt): - evt.preventDefault() - evt.stopPropagation() - self.timer_mode = self.TIMER_MODES[ - (self.TIMER_MODES.index(self.timer_mode) + 1) % len(self.TIMER_MODES) - ] - - def on_change_volume_click(self, evt): - evt.stopPropagation() - self.player.muted = not self.player.muted - - def on_fullscreen_click(self, evt): - evt.stopPropagation() - try: - fullscreen_elt = document.fullscreenElement - request_fullscreen = self.media_player_elt.requestFullscreen - except AttributeError: - print("fullscreen is not available on this browser") - else: - if fullscreen_elt == None: - print("requesting fullscreen") - request_fullscreen() - else: - print(f"leaving fullscreen: {fullscreen_elt}") - try: - document.exitFullscreen() - except AttributeError: - print("exitFullscreen not available on this browser") - - def on_progress_click(self, evt): - evt.stopPropagation() - position = evt.offsetX / evt.target.width - new_time = self.player.duration * position - self.player.currentTime = new_time - - def on_play(self, evt): - self.media_player_elt.classList.add("playing") - self.show_controls() - self.media_player_elt.bind("mousemove", self.on_mouse_move) - - def on_pause(self, evt): - self.media_player_elt.classList.remove("playing") - self.show_controls() - self.media_player_elt.unbind("mousemove") - - def on_timeupdate(self, evt): - self.update_progress() - - def on_ended(self, evt): - self.update_progress() - - def on_volumechange(self, evt): - evt.stopPropagation() - if self.player.muted: - self.media_player_elt.classList.add("muted") - else: - self.media_player_elt.classList.remove("muted") - - def on_mouse_move(self, evt): - self.show_controls() - - def update_progress(self): - duration = self.player.duration - current_time = duration if self.player.ended else self.player.currentTime - self.progress_elt.max = duration - self.progress_elt.value = current_time - self.progress_elt.text = f"{current_time/duration*100:.02f}" - current_time, duration = int(current_time), int(duration) - if self.timer_mode == "timer": - cur_min, cur_sec = divmod(current_time, 60) - tot_min, tot_sec = divmod(duration, 60) - self.timer_elt.text = f"{cur_min}:{cur_sec:02d}/{tot_min}:{tot_sec:02d}" - elif self.timer_mode == "remaining": - rem_min, rem_sec = divmod(duration - current_time, 60) - self.timer_elt.text = f"{rem_min}:{rem_sec:02d}" - else: - print(f"ERROR: unknown timer mode: {self.timer_mode}") - - def hide_controls(self): - self.controls_elt.classList.add("hidden") - self.media_player_elt.style.cursor = "none" - if self.hide_controls_timer is not None: - timer.clear_timeout(self.hide_controls_timer) - self.hide_controls_timer = None - - def show_controls(self): - self.controls_elt.classList.remove("hidden") - self.media_player_elt.style.cursor = "" - if self.hide_controls_timer is not None: - timer.clear_timeout(self.hide_controls_timer) - if self.player.paused: - self.hide_controls_timer = None - else: - self.hide_controls_timer = timer.set_timeout(self.hide_controls, 3000) - - @classmethod - def do_imports(cls): - # we do imports (notably for ogv.js) only if they are necessary - if cls.imports_done: - return - if not cls.native: - from js_modules import ogv - cls.ogv = ogv - if not ogv.OGVCompat.supported('OGVPlayer'): - print("Can't use OGVPlayer with this browser") - raise NotImplementedError - import template - global media_player_tpl - media_player_tpl = template.Template("components/media_player.html") - cls.imports_done = True - - @staticmethod - def get_source_ext(source): - try: - ext = f".{source.rsplit('.', 1)[1].strip()}" - except IndexError: - return None - return ext or None - - @classmethod - def install(cls, cant_play): - cls.native = False - ext_list = set() - for data in cant_play.values(): - ext_list.update(data['ext']) - cls.cant_play_ext_list = ext_list - for to_rpl_vid_elt in document.body.select('video'): - sources = [] - src = (to_rpl_vid_elt.src or '').strip() - if src: - sources.append(src) - - for source_elt in to_rpl_vid_elt.select('source'): - src = (source_elt.src or '').strip() - if src: - sources.append(src) - - # FIXME: we only use first found source - try: - source = sources[0] - except IndexError: - print(f"Can't find any source for following elt:\n{to_rpl_vid_elt.html}") - continue - - ext = cls.get_source_ext(source) - - ext = f".{source.rsplit('.', 1)[1]}" - if ext is None: - print( - "No extension found for source of following elt:\n" - f"{to_rpl_vid_elt.html}" - ) - continue - if ext in ext_list: - print(f"alternative player will be used for {source!r}") - cls(sources, to_rpl_vid_elt) - - -def install_if_needed(): - CONTENT_TYPES = { - "ogg_theora": {"type": 'video/ogg; codecs="theora"', "ext": [".ogv", ".ogg"]}, - "webm_vp8": {"type": 'video/webm; codecs="vp8, vorbis"', "ext": [".webm"]}, - "webm_vp9": {"type": 'video/webm; codecs="vp9"', "ext": [".webm"]}, - # FIXME: handle audio - # "ogg_vorbis": {"type": 'audio/ogg; codecs="vorbis"', "ext": ".ogg"}, - } - test_media_elt = html.VIDEO() - cant_play = {k:d for k,d in CONTENT_TYPES.items() - if test_media_elt.canPlayType(d['type']) != "probably"} - - if cant_play: - cant_play_list = '\n'.join(f"- {k} ({d['type']})" for k, d in cant_play.items()) - print( - "This browser is incompatible with following content types, using " - f"alternative:\n{cant_play_list}" - ) - try: - MediaPlayer.install(cant_play) - except NotImplementedError: - pass - else: - print("This browser can play natively all requested open video/audio formats")
--- a/libervia/pages/_browser/bridge.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,348 +0,0 @@ -from browser import window, aio, timer, console as log -import time -import random -import json -import dialog -import javascript - - -log.warning = log.warn -tab_id = random.randint(0, 2**64) -log.info(f"TAB ID is {tab_id}") - - -class BridgeException(Exception): - """An exception which has been raised from the backend and arrived to the frontend.""" - - def __init__(self, name, message="", condition=""): - """ - - @param name (str): full exception class name (with module) - @param message (str): error message - @param condition (str) : error condition - """ - Exception.__init__(self) - self.fullname = str(name) - self.message = str(message) - self.condition = str(condition) if condition else "" - self.module, __, self.classname = str(self.fullname).rpartition(".") - - def __str__(self): - return f"{self.classname}: {self.message or ''}" - - def __eq__(self, other): - return self.classname == other - - -class WebSocket: - - def __init__(self, broadcast_channel): - self.broadcast_channel = broadcast_channel - self.token = window.ws_token - self.create_socket() - self.retrying = False - self.network_error = False - - @property - def profile(self): - return self.broadcast_channel.profile - - def retry_connect(self) -> None: - if self.retrying: - return - self.retrying = True - try: - notif = dialog.RetryNotification(self.create_socket) - notif.show( - "Can't connect to server", - delay=random.randint(0, 30) - ) - except Exception as e: - # for security reasons, browser don't give the reason of the error with - # WebSockets, thus we try to detect network error here, as if we can't show - # the retry dialog, that probably means that it's not reachable - try: - name = e.name - except AttributeError: - name = None - if name == "NetworkError": - self.network_error = True - log.warning("network error detected, server may be down") - log.error(f"Can't show retry dialog: {e}") - log.info("retrying in 30s") - timer.set_timeout(self.create_socket, 30000) - else: - raise e - else: - # if we can show the retry dialog, the network is fine - self.network_error = False - - def create_socket(self) -> None: - log.debug("creating socket") - self.retrying = False - self.socket = window.WebSocket.new(window.ws_url, "libervia-page") - self.socket_start = time.time() - self.socket.bind("open", self.on_open) - self.socket.bind("error", self.on_error) - self.socket.bind("close", self.on_close) - self.socket.bind("message", self.on_message) - - def send(self, data_type: str, data: dict) -> None: - self.socket.send(json.dumps({ - "type": data_type, - "data": data - })) - - def close(self) -> None: - log.debug("closing socket") - self.broadcast_channel.ws = None - self.socket.close() - - def on_open(self, evt) -> None: - log.info("websocket connection opened") - self.send("init", {"profile": self.profile, "token": self.token}) - - def on_error(self, evt) -> None: - if not self.network_error and time.time() - self.socket_start < 5: - # disconnection is happening fast, we try to reload - log.warning("Reloading due to suspected session error") - window.location.reload() - else: - self.retry_connect() - - def on_close(self, evt) -> None: - log.warning(f"websocket is closed {evt.code=} {evt.reason=}") - if self.broadcast_channel.ws is None: - # this is a close requested locally - return - elif evt.code == 4401: - log.info( - "no authorized, the session is probably not valid anymore, reloading" - ) - window.location.reload() - else: - # close event may be due to normal tab closing, thus we try to reconnect only - # after a delay - timer.set_timeout(self.retry_connect, 5000) - - def on_message(self, message_evt): - msg_data = json.loads(message_evt.data) - msg_type = msg_data.get("type") - if msg_type == "bridge": - log.debug( - f"==> bridge message: {msg_data=}" - ) - self.broadcast_channel.post( - msg_type, - msg_data["data"] - ) - elif msg_type == "force_close": - log.warning(f"force closing connection: {msg_data.get('reason')}") - self.close() - else: - dialog.notification.show( - f"Unexpected message type {msg_type}" - "error" - ) - - -class BroadcastChannel: - handlers = {} - - def __init__(self): - log.debug(f"BroadcastChannel init with profile {self.profile!r}") - self.start = time.time() - self.bc = window.BroadcastChannel.new("libervia") - self.bc.bind("message", self.on_message) - # there is no way to check if there is already a connection in BroadcastChannel - # API, thus we wait a bit to see if somebody is answering. If not, we are probably - # the first tab. - self.check_connection_timer = timer.set_timeout(self.establish_connection, 20) - self.ws = None - # set of all known tab ids - self.tabs_ids = {tab_id} - self.post("salut_a_vous", { - "id": tab_id, - "profile": self.profile - }) - window.bind("unload", self.on_unload) - - @property - def profile(self): - return window.profile or "" - - @property - def connecting_tab(self) -> bool: - """True is this tab is the one establishing the websocket connection""" - return self.ws is not None - - @connecting_tab.setter - def connecting_tab(self, connecting: bool) -> None: - if connecting: - if self.ws is None: - self.ws = WebSocket(self) - self.post("connection", { - "tab_id": tab_id - }) - elif self.ws is not None: - self.ws.close() - - def establish_connection(self) -> None: - """Called when there is no existing connection""" - timer.clear_timeout(self.check_connection_timer) - log.debug(f"Establishing connection {tab_id=}") - self.connecting_tab = True - - def handle_bridge_signal(self, data: dict) -> None: - """Forward bridge signals to registered handlers""" - signal = data["signal"] - handlers = self.handlers.get(signal, []) - for handler in handlers: - handler(*data["args"]) - - def on_message(self, evt) -> None: - data = json.loads(evt.data) - if data["type"] == "bridge": - self.handle_bridge_signal(data) - elif data["type"] == "salut_a_toi": - # this is a response from existing tabs - other_tab_id = data["id"] - if other_tab_id == tab_id: - # in the unlikely case that this happens, we simply reload this tab to get - # a new ID - log.warning("duplicate tab id, we reload the page: {tab_id=}") - window.location.reload() - return - self.tabs_ids.add(other_tab_id) - if data["connecting_tab"] and self.check_connection_timer is not None: - # this tab has the websocket connection to server - log.info(f"there is already a connection to server at tab {other_tab_id}") - timer.clear_timeout(self.check_connection_timer) - self.check_connection_timer = None - elif data["type"] == "salut_a_vous": - # a new tab has just been created - if data["profile"] != self.profile: - log.info( - f"we are now connected with the profile {data['profile']}, " - "reloading the page" - ) - window.location.reload() - else: - self.tabs_ids.add(data["id"]) - self.post("salut_a_toi", { - "id": tab_id, - "connecting_tab": self.connecting_tab - }) - elif data["type"] == "connection": - log.info(f"tab {data['id']} is the new connecting tab") - elif data["type"] == "salut_a_rantanplan": - # a tab is being closed - other_tab_id = data["id"] - # it is unlikely that there is a collision, but just in case we check it - if other_tab_id != tab_id: - self.tabs_ids.discard(other_tab_id) - if data["connecting_tab"]: - log.info(f"connecting tab with id {other_tab_id} has been closed") - if max(self.tabs_ids) == tab_id: - log.info("this is the new connecting tab, establish_connection") - self.connecting_tab = True - else: - log.info(f"tab with id {other_tab_id} has been closed") - else: - log.warning(f"unknown message type: {data}") - - def post(self, data_type, data: dict): - data["type"] = data_type - data["id"] = tab_id - self.bc.postMessage(json.dumps(data)) - if data_type == "bridge": - self.handle_bridge_signal(data) - - def on_unload(self, evt) -> None: - """Send a message to indicate that the tab is being closed""" - self.post("salut_a_rantanplan", { - "id": tab_id, - "connecting_tab": self.connecting_tab - }) - - -class Bridge: - bc: BroadcastChannel | None = None - - def __init__(self) -> None: - if Bridge.bc is None: - Bridge.bc = BroadcastChannel() - - def __getattr__(self, attr): - return lambda *args, **kwargs: self.call(attr, *args, **kwargs) - - def on_load(self, xhr, ev, callback, errback): - if xhr.status == 200: - ret = javascript.JSON.parse(xhr.response) - if callback is not None: - if ret is None: - callback() - else: - callback(ret) - elif xhr.status == 502: - # PROXY_ERROR is used for bridge error - ret = javascript.JSON.parse(xhr.response) - if errback is not None: - errback(ret) - else: - log.error( - f"bridge call failed: code: {xhr.response}, text: {xhr.statusText}" - ) - if errback is not None: - errback({"fullname": "BridgeInternalError", "message": xhr.statusText}) - - def call(self, method_name, *args, callback, errback, **kwargs): - xhr = window.XMLHttpRequest.new() - xhr.bind('load', lambda ev: self.on_load(xhr, ev, callback, errback)) - xhr.bind('error', lambda ev: errback( - {"fullname": "ConnectionError", "message": xhr.statusText})) - xhr.open("POST", f"/_bridge/{method_name}", True) - data = javascript.JSON.stringify({ - "args": args, - "kwargs": kwargs, - }) - xhr.setRequestHeader('X-Csrf-Token', window.csrf_token) - xhr.send(data) - - def register_signal(self, signal: str, handler, iface=None) -> None: - BroadcastChannel.handlers.setdefault(signal, []).append(handler) - log.debug(f"signal {signal} has been registered") - - -class AsyncBridge: - - def __getattr__(self, attr): - return lambda *args, **kwargs: self.call(attr, *args, **kwargs) - - async def call(self, method_name, *args, **kwargs): - print(f"calling {method_name}") - data = javascript.JSON.stringify({ - "args": args, - "kwargs": kwargs, - }) - url = f"/_bridge/{method_name}" - r = await aio.post( - url, - headers={ - 'X-Csrf-Token': window.csrf_token, - }, - data=data, - ) - - if r.status == 200: - return javascript.JSON.parse(r.data) - elif r.status == 502: - ret = javascript.JSON.parse(r.data) - raise BridgeException(ret['fullname'], ret['message'], ret['condition']) - else: - print(f"bridge called failed: code: {r.status}, text: {r.statusText}") - raise BridgeException("InternalError", r.statusText) - - def register_signal(self, signal: str, handler, iface=None) -> None: - BroadcastChannel.handlers.setdefault(signal, []).append(handler) - log.debug(f"signal {signal} has been registered")
--- a/libervia/pages/_browser/browser_meta.json Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -{ - "js": { - "package": { - "dependencies": { - "nunjucks": "^3.2.3", - "swiper": "^6.8.4", - "moment": "^2.29.1", - "ogv": "^1.8.4" - } - }, - "brython_map": { - "nunjucks": "nunjucks/browser/nunjucks.min.js", - "swiper": { - "path": "swiper/swiper-bundle.min.js", - "export": ["Swiper"] - }, - "moment": "moment/min/moment.min.js", - "ogv": { - "path": "ogv/dist/ogv.js", - "export": ["OGVCompat", "OGVLoader", "OGVMediaError", "OGVMediaType", "OGVTimeRanges", "OGVPlayer", "OGVVersion"], - "extra_init": "OGVLoader.base='/{build_dir}/node_modules/ogv/dist'" - } - } - } -}
--- a/libervia/pages/_browser/cache.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,156 +0,0 @@ -from browser import window -from browser.local_storage import storage -from javascript import JSON -from dialog import notification -from bridge import Bridge - -session_uuid = window.session_uuid -bridge = Bridge() - -# XXX: we don't use browser.object_storage because it is affected by -# https://github.com/brython-dev/brython/issues/1467 and mixing local_storage.storage -# and object_storage was resulting in weird behaviour (keys found in one not in the -# other) - - -class Cache: - - def __init__(self): - try: - cache = storage['libervia_cache'] - except KeyError: - self.request_data_from_backend() - else: - cache = JSON.parse(cache) - if cache['metadata']['session_uuid'] != session_uuid: - print("data in cache are not valid for this session, resetting") - del storage['libervia_cache'] - self.request_data_from_backend() - else: - self._cache = cache - print("storage cache is used") - - @property - def roster(self): - return self._cache['roster'] - - @property - def identities(self): - return self._cache['identities'] - - def update(self): - # FIXME: we use window.JSON as a workaround to - # https://github.com/brython-dev/brython/issues/1467 - print(f"updating: {self._cache}") - storage['libervia_cache'] = window.JSON.stringify(self._cache) - print("cache stored") - - def _store_if_complete(self): - self._completed_count -= 1 - if self._completed_count == 0: - del self._completed_count - self.update() - - def get_contacts_cb(self, contacts): - print("roster received") - roster = self._cache['roster'] - for contact_jid, attributes, groups in contacts: - roster[contact_jid] = { - 'attributes': attributes, - 'groups': groups, - } - self._store_if_complete() - - def identities_base_get_cb(self, identities_raw): - print("base identities received") - identities = JSON.parse(identities_raw) - self._cache['identities'].update(identities) - self._store_if_complete() - - def request_failed(self, exc, message): - notification.show(message.format(exc=exc), "error") - self._store_if_complete() - - def request_data_from_backend(self): - self._cache = { - 'metadata': { - "session_uuid": session_uuid, - }, - 'roster': {}, - 'identities': {}, - } - self._completed_count = 2 - print("requesting roster to backend") - bridge.contacts_get( - callback=self.get_contacts_cb, - errback=lambda e: self.request_failed(e, "Can't get contacts: {exc}") - ) - print("requesting base identities to backend") - bridge.identities_base_get( - callback=self.identities_base_get_cb, - errback=lambda e: self.request_failed(e, "Can't get base identities: {exc}") - ) - - def _fill_identities_cb(self, new_identities_raw, callback): - new_identities = JSON.parse(new_identities_raw) - print(f"new identities: {new_identities.keys()}") - self._cache['identities'].update(new_identities) - self.update() - if callback: - callback() - - def fill_identities(self, entities, callback=None): - """Check that identities for entities exist, request them otherwise""" - to_get = {e for e in entities if e not in self._cache['identities']} - if to_get: - bridge.identities_get( - list(to_get), - ['avatar', 'nicknames'], - callback=lambda identities: self._fill_identities_cb( - identities, callback), - errback=lambda failure_: notification.show( - f"Can't get identities: {failure_}", - "error" - ) - ) - else: - # we already have all identities - print("no missing identity") - if callback: - callback() - - def match_identity(self, entity_jid, text, identity=None): - """Returns True if a text match an entity identity - - identity will be matching if its jid or any of its name contain text - @param entity_jid: jid of the entity to check - @param text: text to use for filtering. Must be in lowercase and stripped - @param identity: identity data - if None, it will be retrieved if jid is not matching - @return: True if entity is matching - """ - if text in entity_jid: - return True - if identity is None: - try: - identity = self.identities[entity_jid] - except KeyError: - print(f"missing identity: {entity_jid}") - return False - return any(text in n.lower() for n in identity['nicknames']) - - def matching_identities(self, text): - """Return identities corresponding to a text - - """ - text = text.lower().strip() - for entity_jid, identity in self._cache['identities'].items(): - if ((text in entity_jid - or any(text in n.lower() for n in identity['nicknames']) - )): - yield entity_jid - - -cache = Cache() -roster = cache.roster -identities = cache.identities
--- a/libervia/pages/_browser/dialog.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,131 +0,0 @@ -"""manage common dialogs""" - -from browser import document, window, timer, console as log -from template import Template - -log.warning = log.warn - - -class Confirm: - - def __init__(self, message, ok_label="", cancel_label="", ok_color="success"): - self._tpl = Template("dialogs/confirm.html") - self.message = message - self.ok_label = ok_label - assert ok_color in ("success", "danger") - self.ok_color = ok_color - self.cancel_label = cancel_label - - def cancel_cb(self, evt, notif_elt): - notif_elt.remove() - - def show(self, ok_cb, cancel_cb=None): - if cancel_cb is None: - cancel_cb = self.cancel_cb - notif_elt = self._tpl.get_elt({ - "message": self.message, - "ok_label": self.ok_label, - "ok_color": self.ok_color, - "cancel_label": self.cancel_label, - }) - - document['notifs_area'] <= notif_elt - timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) - for cancel_elt in notif_elt.select(".click_to_cancel"): - cancel_elt.bind("click", lambda evt: cancel_cb(evt, notif_elt)) - for cancel_elt in notif_elt.select(".click_to_ok"): - cancel_elt.bind("click", lambda evt: ok_cb(evt, notif_elt)) - - def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed): - evt.stopPropagation() - notif_elt.remove() - resolve_cb(confirmed) - - async def ashow(self): - return window.Promise.new( - lambda resolve_cb, reject_cb: - self.show( - lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True), - lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False) - ) - ) - - -class Notification: - - def __init__(self): - self._tpl = Template("dialogs/notification.html") - - def close(self, notif_elt): - notif_elt.classList.remove('state_appended') - notif_elt.bind("transitionend", lambda __: notif_elt.remove()) - - def show( - self, - message: str, - level: str = "info", - delay: int = 5 - ) -> None: - # we log in console error messages, may be useful - if level in ("warning", "error"): - print(f"[{level}] {message}") - notif_elt = self._tpl.get_elt({ - "message": message, - "level": level, - }) - document["notifs_area"] <= notif_elt - timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) - timer.set_timeout(lambda: self.close(notif_elt), delay * 1000) - for elt in notif_elt.select('.click_to_close'): - elt.bind('click', lambda __: self.close(notif_elt)) - - -class RetryNotification: - def __init__(self, retry_cb): - self._tpl = Template("dialogs/retry-notification.html") - self.retry_cb = retry_cb - self.counter = 0 - self.timer = None - - def retry(self, notif_elt): - if self.timer is not None: - timer.clear_interval(self.timer) - self.timer = None - notif_elt.classList.remove('state_appended') - notif_elt.bind("transitionend", lambda __: notif_elt.remove()) - self.retry_cb() - - def update_counter(self, notif_elt): - counter = notif_elt.select_one(".retry_counter") - counter.text = str(self.counter) - self.counter -= 1 - if self.counter < 0: - self.retry(notif_elt) - - def show( - self, - message: str, - level: str = "warning", - delay: int = 5 - ) -> None: - # we log in console error messages, may be useful - if level == "error": - log.error(message) - elif level == "warning": - log.warning(message) - self.counter = delay - notif_elt = self._tpl.get_elt({ - "message": message, - "level": level, - }) - self.update_counter(notif_elt) - document["notifs_area"] <= notif_elt - timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) - self.timer = timer.set_interval(self.update_counter, 1000, notif_elt) - for elt in notif_elt.select('.click_to_retry'): - elt.bind('click', lambda __: self.retry(notif_elt)) - - - - -notification = Notification()
--- a/libervia/pages/_browser/editor.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,234 +0,0 @@ -"""text edition management""" - -from browser import document, window, aio, bind, timer -from browser.local_storage import storage -from browser.object_storage import ObjectStorage -from javascript import JSON -from bridge import AsyncBridge as Bridge, BridgeException -from template import Template -import dialog - -bridge = Bridge() -object_storage = ObjectStorage(storage) -profile = window.profile - -# how often we save forms, in seconds -AUTOSAVE_FREQUENCY = 20 - - -def serialise_form(form_elt): - ret = {} - for elt in form_elt.elements: - if elt.tagName == "INPUT": - if elt.type in ("hidden", "submit"): - continue - elif elt.type == "text": - ret[elt.name] = elt.value - else: - print(f"elt.type not managet yet: {elt.type}") - continue - elif elt.tagName == "TEXTAREA": - ret[elt.name] = elt.value - elif elt.tagName in ("BUTTON",): - continue - else: - print(f"tag not managet yet: {elt.tagName}") - continue - return ret - - -def restore_form(form_elt, data): - for elt in form_elt.elements: - if elt.tagName not in ("INPUT", "TEXTAREA"): - continue - try: - value = data[elt.name] - except KeyError: - continue - else: - elt.value = value - - -def set_form_autosave(form_id): - """Save locally form data regularly and restore it until it's submitted - - form is saved every AUTOSAVE_FREQUENCY seconds and when visibility is lost. - Saved data is restored when the method is called. - Saved data is cleared when the form is submitted. - """ - if profile is None: - print(f"No session started, won't save and restore form {form_id}") - return - - form_elt = document[form_id] - submitted = False - - key = {"profile": profile, "type": "form_autosave", "form": form_id} - try: - form_saved_data = object_storage[key] - except KeyError: - last_serialised = None - else: - print(f"restoring content of form {form_id!r}") - last_serialised = form_saved_data - restore_form(form_elt, form_saved_data) - - def save_form(): - if not submitted: - nonlocal last_serialised - serialised = serialise_form(form_elt) - if serialised != last_serialised: - last_serialised = serialised - print(f"saving content of form {form_id!r}") - object_storage[key] = serialised - - @bind(form_elt, "submit") - def on_submit(evt): - nonlocal submitted - submitted = True - print(f"clearing stored content of form {form_id!r}") - try: - del object_storage[key] - except KeyError: - print("key error") - pass - - @bind(document, "visibilitychange") - def on_visibiliy_change(evt): - print("visibility change") - if document.visibilityState != "visible": - save_form() - - timer.set_interval(save_form, AUTOSAVE_FREQUENCY * 1000) - - -class TagsEditor: - - def __init__(self, input_selector): - print("installing Tags Editor") - self.input_elt = document.select_one(input_selector) - self.input_elt.style.display = "none" - tags_editor_tpl = Template('editor/tags_editor.html') - self.tag_tpl = Template('editor/tag.html') - - editor_elt = tags_editor_tpl.get_elt() - self.input_elt.parent <= editor_elt - self.tag_input_elt = editor_elt.select_one(".tag_input") - self.tag_input_elt.bind("keydown", self.on_key_down) - self._current_tags = None - self.tags_map = {} - for tag in self.current_tags: - self.add_tag(tag, force=True) - - @property - def current_tags(self): - if self._current_tags is None: - self._current_tags = { - t.strip() for t in self.input_elt.value.split(',') if t.strip() - } - return self._current_tags - - @current_tags.setter - def current_tags(self, tags): - self._current_tags = tags - - def add_tag(self, tag, force=False): - tag = tag.strip() - if not force and (not tag or tag in self.current_tags): - return - self.current_tags = self.current_tags | {tag} - self.input_elt.value = ','.join(self.current_tags) - tag_elt = self.tag_tpl.get_elt({"label": tag}) - self.tags_map[tag] = tag_elt - self.tag_input_elt.parent.insertBefore(tag_elt, self.tag_input_elt) - tag_elt.select_one(".click_to_delete").bind( - "click", lambda evt: self.on_tag_click(evt, tag) - ) - - def remove_tag(self, tag): - try: - tag_elt = self.tags_map[tag] - except KeyError: - print(f"trying to remove an inexistant tag: {tag}") - else: - self.current_tags = self.current_tags - {tag} - self.input_elt.value = ','.join(self.current_tags) - tag_elt.remove() - - def on_tag_click(self, evt, tag): - evt.stopPropagation() - self.remove_tag(tag) - - def on_key_down(self, evt): - if evt.key in (",", "Enter"): - evt.stopPropagation() - evt.preventDefault() - self.add_tag(self.tag_input_elt.value) - self.tag_input_elt.value = "" - - -class BlogEditor: - """Editor class, handling tabs, preview, and submit loading button - - It's using and HTML form as source - The form must have: - - a "title" text input - - a "body" textarea - - an optional "tags" text input with comma separated tags (may be using Tags - Editor) - - a "tab_preview" tab element - """ - - def __init__(self, form_id="blog_post_edit"): - self.tab_select = window.tab_select - self.item_tpl = Template('blog/item.html') - self.form = document[form_id] - for elt in document.select(".click_to_edit"): - elt.bind("click", self.on_edit) - for elt in document.select('.click_to_preview'): - elt.bind("click", lambda evt: aio.run(self.on_preview(evt))) - self.form.bind("submit", self.on_submit) - - - def on_edit(self, evt): - self.tab_select(evt.target, "tab_edit", "is-active") - - async def on_preview(self, evt): - """Generate a blog preview from HTML form - - """ - print("on preview OK") - elt = evt.target - tab_preview = document["tab_preview"] - tab_preview.clear() - data = { - "content_rich": self.form.select_one('textarea[name="body"]').value.strip() - } - title = self.form.select_one('input[name="title"]').value.strip() - if title: - data["title_rich"] = title - tags_input_elt = self.form.select_one('input[name="tags"]') - if tags_input_elt is not None: - tags = tags_input_elt.value.strip() - if tags: - data['tags'] = [t.strip() for t in tags.split(',') if t.strip()] - try: - preview_data = JSON.parse( - await bridge.mb_preview("", "", JSON.stringify(data)) - ) - except BridgeException as e: - dialog.notification.show( - f"Can't generate item preview: {e.message}", - level="error" - ) - else: - self.tab_select(elt, "tab_preview", "is-active") - item_elt = self.item_tpl.get_elt({ - "item": preview_data, - "dates_format": "short", - }) - tab_preview <= item_elt - - def on_submit(self, evt): - submit_btn = document.select_one("button[type='submit']") - submit_btn.classList.add("is-loading")
--- a/libervia/pages/_browser/errors.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,2 +0,0 @@ -class TimeoutError(Exception): - """An action has not been done in time"""
--- a/libervia/pages/_browser/invitation.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,444 +0,0 @@ -from browser import document, window, timer -from bridge import Bridge -from template import Template -import dialog -from cache import cache -import javascript - -bridge = Bridge() -# we use JS RegExp because Python's re is really long to import in Brython -# FIXME: this is a naive JID regex, a more accurate should be used instead -jid_re = javascript.RegExp.new(r"^\w+@\w+\.\w+") - - -class InvitationManager: - - def __init__(self, invitation_type, invitation_data): - self.invitation_type = invitation_type - self.invitation_data = invitation_data - manager_panel_tpl = Template('invitation/manager.html') - self.manager_panel_elt = manager_panel_tpl.get_elt() - self.invite_by_email_tpl = Template('invitation/invite_by_email.html') - self.affiliation_tpl = Template('invitation/affiliation_item.html') - self.new_item_tpl = Template('invitation/new_item.html') - # list of item passing filter when adding a new contact - self._filtered_new_items = {} - self._active_new_item = None - self._idx = 0 - - def attach(self, affiliations=None): - if affiliations is None: - affiliations = {} - self.affiliations = affiliations - self.side_panel = self.manager_panel_elt.select_one( - '.invitation_manager_side_panel') - self.open() - for close_elt in self.manager_panel_elt.select('.click_to_close'): - close_elt.bind("click", self.on_manager_close) - self.side_panel.bind("click", lambda evt: evt.stopPropagation()) - - cache.fill_identities(affiliations.keys(), callback=self._set_affiliations) - - contact_elt = self.manager_panel_elt.select_one('input[name="contact"]') - contact_elt.bind("input", self.on_contact_input) - contact_elt.bind("keydown", self.on_contact_keydown) - contact_elt.bind("focus", self.on_contact_focus) - contact_elt.bind("blur", self.on_contact_blur) - document['invite_email'].bind('click', self.on_invite_email_click) - - def _set_affiliations(self): - for entity_jid, affiliation in self.affiliations.items(): - self.set_affiliation(entity_jid, affiliation) - - def open(self): - """Re-attach and show a closed panel""" - self._body_ori_style = document.body.style.height, document.body.style.overflow - document.body.style.height = '100vh' - document.body.style.overflow = 'hidden' - document.body <= self.manager_panel_elt - timer.set_timeout(lambda: self.side_panel.classList.add("open"), 0) - - def _on_close_transition_end(self, evt): - self.manager_panel_elt.remove() - # FIXME: not working with Brython, to report upstream - # self.side_panel.unbind("transitionend", self._on_close_transition_end) - self.side_panel.unbind("transitionend") - - def close(self): - """Hide the panel""" - document.body.style.height, document.body.style.overflow = self._body_ori_style - self.side_panel.classList.remove('open') - self.side_panel.bind("transitionend", self._on_close_transition_end) - - def _invite_jid(self, entity_jid, callback, errback=None): - if errback is None: - errback = lambda e: dialog.notification.show(f"invitation failed: {e}", "error") - if self.invitation_type == 'photos': - service = self.invitation_data["service"] - path = self.invitation_data["path"] - album_name = path.rsplit('/')[-1] - print(f"inviting {entity_jid}") - bridge.fis_invite( - entity_jid, - service, - "photos", - "", - path, - album_name, - '', - callback=callback, - errback=errback - ) - elif self.invitation_type == 'pubsub': - service = self.invitation_data["service"] - node = self.invitation_data["node"] - name = self.invitation_data.get("name") - namespace = self.invitation_data.get("namespace") - extra = {} - if namespace: - extra["namespace"] = namespace - print(f"inviting {entity_jid}") - bridge.ps_invite( - entity_jid, - service, - node, - '', - name, - javascript.JSON.stringify(extra), - callback=callback, - errback=errback - ) - else: - print(f"error: unknown invitation type: {self.invitation_type}") - - def invite_by_jid(self, entity_jid): - self._invite_jid( - entity_jid, - callback=lambda entity_jid=entity_jid: self._on_jid_invitation_success(entity_jid), - ) - - def on_manager_close(self, evt): - self.close() - - def _on_jid_invitation_success(self, entity_jid): - form_elt = document['invitation_form'] - contact_elt = form_elt.select_one('input[name="contact"]') - contact_elt.value = "" - contact_elt.dispatchEvent(window.Event.new('input')) - dialog.notification.show( - f"{entity_jid} has been invited", - level="success", - ) - if entity_jid not in self.affiliations: - self.set_affiliation(entity_jid, "member") - - def on_contact_invite(self, evt, entity_jid): - """User is adding a contact""" - form_elt = document['invitation_form'] - contact_elt = form_elt.select_one('input[name="contact"]') - contact_elt.value = "" - contact_elt.dispatchEvent(window.Event.new('input')) - self.invite_by_jid(entity_jid) - - def on_contact_keydown(self, evt): - if evt.key == "Escape": - evt.target.value = "" - evt.target.dispatchEvent(window.Event.new('input')) - elif evt.key == "ArrowDown": - evt.stopPropagation() - evt.preventDefault() - content_elt = document['invitation_contact_search'].select_one( - ".search_dialog__content") - if self._active_new_item == None: - self._active_new_item = content_elt.firstElementChild - self._active_new_item.classList.add('selected') - else: - next_item = self._active_new_item.nextElementSibling - if next_item is not None: - self._active_new_item.classList.remove('selected') - self._active_new_item = next_item - self._active_new_item.classList.add('selected') - elif evt.key == "ArrowUp": - evt.stopPropagation() - evt.preventDefault() - content_elt = document['invitation_contact_search'].select_one( - ".search_dialog__content") - if self._active_new_item == None: - self._active_new_item = content_elt.lastElementChild - self._active_new_item.classList.add('selected') - else: - previous_item = self._active_new_item.previousElementSibling - if previous_item is not None: - self._active_new_item.classList.remove('selected') - self._active_new_item = previous_item - self._active_new_item.classList.add('selected') - elif evt.key == "Enter": - evt.stopPropagation() - evt.preventDefault() - if self._active_new_item is not None: - entity_jid = self._active_new_item.dataset.entityJid - self.invite_by_jid(entity_jid) - else: - if jid_re.exec(evt.target.value): - self.invite_by_jid(evt.target.value) - evt.target.value = "" - - def on_contact_focus(self, evt): - search_dialog = document['invitation_contact_search'] - search_dialog.classList.add('open') - self._active_new_item = None - evt.target.dispatchEvent(window.Event.new('input')) - - def on_contact_blur(self, evt): - search_dialog = document['invitation_contact_search'] - search_dialog.classList.remove('open') - for elt in self._filtered_new_items.values(): - elt.remove() - self._filtered_new_items.clear() - - - def on_contact_input(self, evt): - text = evt.target.value.strip().lower() - search_dialog = document['invitation_contact_search'] - content_elt = search_dialog.select_one(".search_dialog__content") - for (entity_jid, identity) in cache.identities.items(): - if not cache.match_identity(entity_jid, text, identity): - # if the entity was present in last pass, we remove it - try: - filtered_item = self._filtered_new_items.pop(entity_jid) - except KeyError: - pass - else: - filtered_item.remove() - continue - if entity_jid not in self._filtered_new_items: - # we only create a new element if the item was not already there - new_item_elt = self.new_item_tpl.get_elt({ - "entity_jid": entity_jid, - "identities": cache.identities, - }) - content_elt <= new_item_elt - self._filtered_new_items[entity_jid] = new_item_elt - for elt in new_item_elt.select('.click_to_ok'): - # we use mousedown instead of click because otherwise it would be - # ignored due to "blur" event manager (see - # https://stackoverflow.com/a/9335401) - elt.bind( - "mousedown", - lambda evt, entity_jid=entity_jid: self.on_contact_invite( - evt, entity_jid), - ) - - if ((self._active_new_item is not None - and not self._active_new_item.parentElement)): - # active item has been filtered out - self._active_new_item = None - - def _on_email_invitation_success(self, invitee_jid, email, name): - self.set_affiliation(invitee_jid, "member") - dialog.notification.show( - f"{name} has been invited, he/she has received an email with a link", - level="success", - ) - - def invitation_simple_create_cb(self, invitation_data, email, name): - invitee_jid = invitation_data['jid'] - self._invite_jid( - invitee_jid, - callback=lambda: self._on_email_invitation_success(invitee_jid, email, name), - errback=lambda e: dialog.notification.show( - f"invitation failed for {email}: {e}", - "error" - ) - ) - - # we update identities to have the name instead of the invitation jid in - # affiliations - cache.identities[invitee_jid] = {'nicknames': [name]} - cache.update() - - def invite_by_email(self, email, name): - guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}' - bridge.invitation_simple_create( - email, - name, - guest_url_tpl, - '', - callback=lambda data: self.invitation_simple_create_cb(data, email, name), - errback=lambda e: window.alert(f"can't send email invitation: {e}") - ) - - def on_invite_email_submit(self, evt, invite_email_elt): - evt.stopPropagation() - evt.preventDefault() - form = document['email_invitation_form'] - try: - reportValidity = form.reportValidity - except AttributeError: - print("reportValidity is not supported by this browser!") - else: - if not reportValidity(): - return - email = form.select_one('input[name="email"]').value - name = form.select_one('input[name="name"]').value - self.invite_by_email(email, name) - invite_email_elt.remove() - self.open() - - def on_invite_email_close(self, evt, invite_email_elt): - evt.stopPropagation() - evt.preventDefault() - invite_email_elt.remove() - self.open() - - def on_invite_email_click(self, evt): - evt.stopPropagation() - evt.preventDefault() - invite_email_elt = self.invite_by_email_tpl.get_elt() - document.body <= invite_email_elt - document['email_invitation_submit'].bind( - 'click', lambda evt: self.on_invite_email_submit(evt, invite_email_elt) - ) - for close_elt in invite_email_elt.select('.click_to_close'): - close_elt.bind( - "click", lambda evt: self.on_invite_email_close(evt, invite_email_elt)) - self.close() - - ## affiliations - - def _add_affiliation_bindings(self, entity_jid, affiliation_elt): - for elt in affiliation_elt.select(".click_to_delete"): - elt.bind( - "click", - lambda evt, entity_jid=entity_jid, affiliation_elt=affiliation_elt: - self.on_affiliation_remove(entity_jid, affiliation_elt) - ) - for elt in affiliation_elt.select(".click_to_set_publisher"): - try: - name = cache.identities[entity_jid]["nicknames"][0] - except (KeyError, IndexError): - name = entity_jid - elt.bind( - "click", - lambda evt, entity_jid=entity_jid, name=name, - affiliation_elt=affiliation_elt: - self.on_affiliation_set( - entity_jid, name, affiliation_elt, "publisher" - ), - ) - for elt in affiliation_elt.select(".click_to_set_member"): - try: - name = cache.identities[entity_jid]["nicknames"][0] - except (KeyError, IndexError): - name = entity_jid - elt.bind( - "click", - lambda evt, entity_jid=entity_jid, name=name, - affiliation_elt=affiliation_elt: - self.on_affiliation_set( - entity_jid, name, affiliation_elt, "member" - ), - ) - - def set_affiliation(self, entity_jid, affiliation): - if affiliation not in ('owner', 'member', 'publisher'): - raise NotImplementedError( - f'{affiliation} affiliation can not be set with this method for the ' - 'moment') - if entity_jid not in self.affiliations: - self.affiliations[entity_jid] = affiliation - affiliation_elt = self.affiliation_tpl.get_elt({ - "entity_jid": entity_jid, - "affiliation": affiliation, - "identities": cache.identities, - }) - document['affiliations'] <= affiliation_elt - self._add_affiliation_bindings(entity_jid, affiliation_elt) - - def _on_affiliation_remove_success(self, affiliation_elt, entity_jid): - affiliation_elt.remove() - del self.affiliations[entity_jid] - - def on_affiliation_remove(self, entity_jid, affiliation_elt): - if self.invitation_type == 'photos': - path = self.invitation_data["path"] - service = self.invitation_data["service"] - bridge.fis_affiliations_set( - service, - "", - path, - {entity_jid: "none"}, - callback=lambda: self._on_affiliation_remove_success( - affiliation_elt, entity_jid), - errback=lambda e: dialog.notification.show( - f"can't remove affiliation: {e}", "error") - ) - elif self.invitation_type == 'pubsub': - service = self.invitation_data["service"] - node = self.invitation_data["node"] - bridge.ps_node_affiliations_set( - service, - node, - {entity_jid: "none"}, - callback=lambda: self._on_affiliation_remove_success( - affiliation_elt, entity_jid), - errback=lambda e: dialog.notification.show( - f"can't remove affiliation: {e}", "error") - ) - else: - dialog.notification.show( - f"error: unknown invitation type: {self.invitation_type}", - "error" - ) - - def _on_affiliation_set_success(self, entity_jid, name, affiliation_elt, affiliation): - dialog.notification.show(f"permission updated for {name}") - self.affiliations[entity_jid] = affiliation - new_affiliation_elt = self.affiliation_tpl.get_elt({ - "entity_jid": entity_jid, - "affiliation": affiliation, - "identities": cache.identities, - }) - affiliation_elt.replaceWith(new_affiliation_elt) - self._add_affiliation_bindings(entity_jid, new_affiliation_elt) - - def _on_affiliation_set_ok(self, entity_jid, name, affiliation_elt, affiliation): - if self.invitation_type == 'pubsub': - service = self.invitation_data["service"] - node = self.invitation_data["node"] - bridge.ps_node_affiliations_set( - service, - node, - {entity_jid: affiliation}, - callback=lambda: self._on_affiliation_set_success( - entity_jid, name, affiliation_elt, affiliation - ), - errback=lambda e: dialog.notification.show( - f"can't set affiliation: {e}", "error") - ) - else: - dialog.notification.show( - f"error: unknown invitation type: {self.invitation_type}", - "error" - ) - - def _on_affiliation_set_cancel(self, evt, notif_elt): - notif_elt.remove() - self.open() - - def on_affiliation_set(self, entity_jid, name, affiliation_elt, affiliation): - if affiliation == "publisher": - message = f"Give autorisation to publish to {name}?" - elif affiliation == "member": - message = f"Remove autorisation to publish from {name}?" - else: - dialog.notification.show(f"unmanaged affiliation: {affiliation}", "error") - return - dialog.Confirm(message).show( - ok_cb=lambda evt, notif_elt: - self._on_affiliation_set_ok( - entity_jid, name, affiliation_elt, affiliation - ), - cancel_cb=self._on_affiliation_set_cancel - ) - self.close()
--- a/libervia/pages/_browser/loading.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,7 +0,0 @@ -"""manage common dialogs""" - -from browser import document - -def remove_loading_screen(): - print("removing loading screen") - document['loading_screen'].remove()
--- a/libervia/pages/_browser/slideshow.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,262 +0,0 @@ -from browser import document, window, html, timer, DOMNode -from js_modules.swiper import Swiper -from template import Template - - -class SlideShow: - - def __init__(self): - self.swiper = None - slideshow_tpl = Template('photo/slideshow.html') - self.slideshow_elt = slideshow_tpl.get_elt() - self.comments_count_elt = self.slideshow_elt.select_one('.comments__count') - self.wrapper = self.slideshow_elt.select_one(".swiper-wrapper") - self.hidden_elts = {} - self.control_hidden = False - self.click_timer = None - self._class_to_remove = set() - self._exit_callback = None - - @property - def current_slide(self): - if self.swiper is None: - return None - try: - return DOMNode(self.swiper.slides[self.swiper.realIndex]) - # getting missing item in JS arrays returns KeyError - except KeyError: - return None - - @property - def current_item(self): - """item attached to the current slide, if any""" - current = self.current_slide - if current is None: - return None - try: - return current._item - except AttributeError: - return None - - @property - def current_options(self): - """options attached to the current slide, if any""" - current = self.current_slide - if current is None: - return None - try: - return current._options - except AttributeError: - return None - - @property - def index(self): - if self.swiper is None: - return None - return self.swiper.realIndex - - @index.setter - def index(self, idx): - if self.swiper is not None: - self.swiper.slideTo(idx, 0) - - def attach(self): - # we hide other elts to avoid scrolling issues - for elt in document.body.children: - try: - self.hidden_elts[elt] = elt.style.display - except AttributeError: - pass - # FIXME: this is a workaround needed because Brython's children method - # is returning all nodes, - # cf. https://github.com/brython-dev/brython/issues/1657 - # to be removed when Brython is fixed. - else: - elt.style.display = "none" - document.body <= self.slideshow_elt - self.swiper = Swiper.new( - ".swiper-container", - { - # default 0 value results in lot of accidental swipes, notably when media - # player is used - "threshold": 10, - "pagination": { - "el": ".swiper-pagination", - }, - "navigation": { - "nextEl": ".swiper-button-next", - "prevEl": ".swiper-button-prev", - }, - "scrollbar": { - "el": ".swiper-scrollbar", - }, - "grabCursor": True, - "keyboard": { - "enabled": True, - "onlyInViewport": False, - }, - "mousewheel": True, - "zoom": { - "maxRatio": 15, - "toggle": False, - }, - } - ) - window.addEventListener("keydown", self.on_key_down, True) - self.slideshow_elt.select_one(".click_to_close").bind("click", self.on_close) - self.slideshow_elt.select_one(".click_to_comment").bind("click", self.on_comment) - - # we don't use swiper.on for "click" and "dblclick" (or "doubleTap" in swiper - # terms) because it breaks event propagation management, which cause trouble with - # media player - self.slideshow_elt.bind("click", self.on_click) - self.slideshow_elt.bind("dblclick", self.on_dblclick) - self.swiper.on("slideChange", self.on_slide_change) - self.on_slide_change(self.swiper) - self.fullscreen(True) - - def add_slide(self, elt, item_data=None, options=None): - slide_elt = html.DIV(Class="swiper-slide") - zoom_cont_elt = html.DIV(Class="swiper-zoom-container") - slide_elt <= zoom_cont_elt - zoom_cont_elt <= elt - slide_elt._item = item_data - if options is not None: - slide_elt._options = options - self.swiper.appendSlide([slide_elt]) - self.swiper.update() - - def quit(self): - # we unhide - for elt, display in self.hidden_elts.items(): - elt.style.display = display - self.hidden_elts.clear() - self.slideshow_elt.remove() - self.slideshow_elt = None - self.swiper.destroy(True, True) - self.swiper = None - - def fullscreen(self, active=None): - """Activate/desactivate fullscreen - - @param acvite: can be: - - True to activate - - False to desactivate - - Auto to switch fullscreen mode - """ - try: - fullscreen_elt = document.fullscreenElement - request_fullscreen = self.slideshow_elt.requestFullscreen - except AttributeError: - print("fullscreen is not available on this browser") - else: - if active is None: - active = fullscreen_elt == None - if active: - request_fullscreen() - else: - try: - document.exitFullscreen() - except AttributeError: - print("exitFullscreen not available on this browser") - - def on_key_down(self, evt): - if evt.key == 'Escape': - self.quit() - else: - return - evt.preventDefault() - - def on_slide_change(self, swiper): - if self._exit_callback is not None: - self._exit_callback() - self._exit_callback = None - item = self.current_item - if item is not None: - comments_count = item.get('comments_count') - self.comments_count_elt.text = comments_count or '' - - for cls in self._class_to_remove: - self.slideshow_elt.classList.remove(cls) - - self._class_to_remove.clear() - - options = self.current_options - if options is not None: - for flag in options.get('flags', []): - cls = f"flag_{flag.lower()}" - self.slideshow_elt.classList.add(cls) - self._class_to_remove.add(cls) - self._exit_callback = options.get("exit_callback", None) - - def toggle_hide_controls(self, evt): - self.click_timer = None - # we don't want to hide controls when a control is clicked - # so we check all ancestors if we are not in a control - current = evt.target - while current and current != self.slideshow_elt: - print(f"current: {current}") - if 'slideshow_control' in current.classList: - return - current = current.parent - for elt in self.slideshow_elt.select('.slideshow_control'): - elt.style.display = '' if self.control_hidden else 'none' - self.control_hidden = not self.control_hidden - - def on_click(self, evt): - evt.stopPropagation() - evt.preventDefault() - # we use a timer so double tap can cancel the click - # this avoid double tap side effect - if self.click_timer is None: - self.click_timer = timer.set_timeout( - lambda: self.toggle_hide_controls(evt), 300) - - def on_dblclick(self, evt): - evt.stopPropagation() - evt.preventDefault() - if self.click_timer is not None: - timer.clear_timeout(self.click_timer) - self.click_timer = None - if self.swiper.zoom.scale != 1: - self.swiper.zoom.toggle() - else: - # "in" is reserved in Python, so we call it using dict syntax - self.swiper.zoom["in"]() - - def on_close(self, evt): - evt.stopPropagation() - evt.preventDefault() - self.quit() - - def on_comment_close(self, evt): - evt.stopPropagation() - side_panel = self.comments_panel_elt.select_one('.comments_side_panel') - side_panel.classList.remove('open') - side_panel.bind("transitionend", lambda evt: self.comments_panel_elt.remove()) - - def on_comments_panel_click(self, evt): - # we stop stop propagation to avoid the closing of the panel - evt.stopPropagation() - - def on_comment(self, evt): - item = self.current_item - if item is None: - return - comments_panel_tpl = Template('blog/comments_panel.html') - try: - comments = item['comments']['items'] - except KeyError: - comments = [] - self.comments_panel_elt = comments_panel_tpl.get_elt({ - "comments": comments, - "comments_service": item['comments_service'], - "comments_node": item['comments_node'], - - }) - self.slideshow_elt <= self.comments_panel_elt - side_panel = self.comments_panel_elt.select_one('.comments_side_panel') - timer.set_timeout(lambda: side_panel.classList.add("open"), 0) - for close_elt in self.comments_panel_elt.select('.click_to_close'): - close_elt.bind("click", self.on_comment_close) - side_panel.bind("click", self.on_comments_panel_click)
--- a/libervia/pages/_browser/template.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,235 +0,0 @@ -"""Integrate templating system using nunjucks""" - -from js_modules.nunjucks import nunjucks -from browser import window, document -import javascript - - -safe = nunjucks.runtime.SafeString.new -env = nunjucks.configure( - window.templates_root_url, - { - 'autoescape': True, - 'trimBlocks': True, - 'lstripBlocks': True, - 'web': {'use_cache': True}, - }) - -nunjucks.installJinjaCompat() -env.addGlobal("profile", window.profile) -env.addGlobal("csrf_token", window.csrf_token) -# FIXME: integrate gettext or equivalent here -env.addGlobal("_", lambda txt: txt) - - -class Indexer: - """Index global to a page""" - - def __init__(self): - self._indexes = {} - - def next(self, value): - if value not in self._indexes: - self._indexes[value] = 0 - return 0 - self._indexes[value] += 1 - return self._indexes[value] - - def current(self, value): - return self._indexes.get(value) - - -gidx = Indexer() -# suffix use to avoid collision with IDs generated in static page -SCRIPT_SUFF = "__script__" - -def escape_html(txt): - return ( - txt - .replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('"', '"') - ) - - -def get_args(n_args, *sig_args, **sig_kwargs): - """Retrieve function args when they are transmitted using nunjucks convention - - cf. https://mozilla.github.io/nunjucks/templating.html#keyword-arguments - @param n_args: argument from nunjucks call - @param sig_args: expected positional arguments - @param sig_kwargs: expected keyword arguments - @return: all expected arguments, with default value if not specified in nunjucks - """ - # nunjucks set kwargs in last argument - given_args = list(n_args) - try: - given_kwargs = given_args.pop().to_dict() - except (AttributeError, IndexError): - # we don't have a dict as last argument - # that happens when there is no keyword argument - given_args = list(n_args) - given_kwargs = {} - ret = given_args[:len(sig_args)] - # we check if we have remaining positional arguments - # in which case they may be specified in keyword arguments - for name in sig_args[len(given_args):]: - try: - value = given_kwargs.pop(name) - except KeyError: - raise ValueError(f"missing positional arguments {name!r}") - ret.append(value) - - extra_pos_args = given_args[len(sig_args):] - # and now the keyword arguments - for name, default in sig_kwargs.items(): - if extra_pos_args: - # kw args has been specified with a positional argument - ret.append(extra_pos_args.pop(0)) - continue - value = given_kwargs.get(name, default) - ret.append(value) - - return ret - - -def _next_gidx(value): - """Use next current global index as suffix""" - next_ = gidx.next(value) - return f"{value}{SCRIPT_SUFF}" if next_ == 0 else f"{value}_{SCRIPT_SUFF}{next_}" - -env.addFilter("next_gidx", _next_gidx) - - -def _cur_gidx(value): - """Use current current global index as suffix""" - current = gidx.current(value) - return f"{value}{SCRIPT_SUFF}" if not current else f"{value}_{SCRIPT_SUFF}{current}" - -env.addFilter("cur_gidx", _cur_gidx) - - -def _xmlattr(d, autospace=True): - if not d: - return - d = d.to_dict() - ret = [''] if autospace else [] - for key, value in d.items(): - if value is not None: - ret.append(f'{escape_html(key)}="{escape_html(str(value))}"') - - return safe(' '.join(ret)) - -env.addFilter("xmlattr", _xmlattr) - - -def _tojson(value): - return safe(escape_html(window.JSON.stringify(value))) - -env.addFilter("tojson", _tojson) - - -def _icon_use(name, cls=""): - kwargs = cls.to_dict() - cls = kwargs.get('cls') - return safe( - '<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" ' - 'viewBox="0 0 100 100">\n' - ' <use href="#{name}"/>' - '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "") - ) - -env.addGlobal("icon", _icon_use) - - -def _date_fmt( - timestamp, *args -): - """Date formatting - - cf. sat.tools.common.date_utils for arguments details - """ - fmt, date_only, auto_limit, auto_old_fmt, auto_new_fmt = get_args( - args, fmt="short", date_only=False, auto_limit=7, auto_old_fmt="short", - auto_new_fmt="relative", - ) - from js_modules.moment import moment - date = moment.unix(timestamp) - - if fmt == "auto_day": - fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm" - if fmt == "auto": - limit = moment().startOf('day').subtract(auto_limit, 'days') - m_fmt = auto_old_fmt if date < limit else auto_new_fmt - - if fmt == "short": - m_fmt = "DD/MM/YY" if date_only else "DD/MM/YY HH:mm" - elif fmt == "medium": - m_fmt = "ll" if date_only else "lll" - elif fmt == "long": - m_fmt = "LL" if date_only else "LLL" - elif fmt == "full": - m_fmt = "dddd, LL" if date_only else "LLLL" - elif fmt == "relative": - return date.fromNow() - elif fmt == "iso": - if date_only: - m_fmt == "YYYY-MM-DD" - else: - return date.toISOString() - else: - raise NotImplementedError("free format is not implemented yet") - - return date.format(m_fmt) - -env.addFilter("date_fmt", _date_fmt) - - -class I18nExtension: - """Extension to handle the {% trans %}{% endtrans %} statement""" - # FIXME: for now there is no translation, this extension only returns the string - # unmodified - tags = ['trans'] - - def parse(self, parser, nodes, lexer): - tok = parser.nextToken() - args = parser.parseSignature(None, True) - parser.advanceAfterBlockEnd(tok.value) - body = parser.parseUntilBlocks('endtrans') - parser.advanceAfterBlockEnd() - return nodes.CallExtension.new(self._js_ext, 'run', args, [body]) - - def run(self, context, *args): - body = args[-1] - return body() - - @classmethod - def install(cls, env): - ext = cls() - ext_dict = { - "tags": ext.tags, - "parse": ext.parse, - "run": ext.run - } - ext._js_ext = javascript.pyobj2jsobj(ext_dict) - env.addExtension(cls.__name__, ext._js_ext) - -I18nExtension.install(env) - - -class Template: - - def __init__(self, tpl_name): - self._tpl = env.getTemplate(tpl_name, True) - - def render(self, context): - return self._tpl.render(context) - - def get_elt(self, context=None): - if context is None: - context = {} - raw_html = self.render(context) - template_elt = document.createElement('template') - template_elt.innerHTML = raw_html - return template_elt.content.firstElementChild
--- a/libervia/pages/_browser/tmp_aio.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -from browser import window - -"""Q&D module to do ajax requests with data types currently unsupported by Brython""" -# FIXME: remove this module when official aio module allows to work with blobs - -window.eval(""" -var _tmp_ajax = function(method, url, format, data){ - return new Promise(function(resolve, reject){ - var xhr = new XMLHttpRequest() - xhr.open(method, url, true) - xhr.responseType = format - xhr.onreadystatechange = function(){ - if(this.readyState == 4){ - resolve(this) - } - } - if(data){ - xhr.send(data) - }else{ - xhr.send() - } - }) -} -""") - -ajax = window._tmp_ajax
--- a/libervia/pages/app/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 - - -name = "app" -template = "app/app.html"
--- a/libervia/pages/blog/edit/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,6 +0,0 @@ -import editor - - -editor.set_form_autosave("blog_post_edit") -editor.BlogEditor() -editor.TagsEditor("input[name='tags']")
--- a/libervia/pages/blog/edit/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from sat.core.log import getLogger -from sat.tools.common import data_format - -log = getLogger(__name__) - -name = "blog_edit" -access = C.PAGES_ACCESS_PROFILE -template = "blog/publish.html" - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if profile is None: - self.page_error(request, C.HTTP_FORBIDDEN) - request_data = self.get_r_data(request) - title, tags, body = self.get_posted_data(request, ('title', 'tags', 'body')) - mb_data = {"content_rich": body, "allow_comments": True} - title = title.strip() - if title: - mb_data["title_rich"] = title - tags = [t.strip() for t in tags.split(',') if t.strip()] - if tags: - mb_data["tags"] = tags - - await self.host.bridge_call( - 'mb_send', - "", - "", - data_format.serialise(mb_data), - profile - ) - - request_data["post_redirect_page"] = self.get_page_by_name("blog")
--- a/libervia/pages/blog/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 - -from sat.core.i18n import _ -from libervia.server.constants import Const as C -from twisted.words.protocols.jabber import jid -from twisted.internet import defer -from libervia.server import session_iface -from sat.tools.common import data_format -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "blog" -access = C.PAGES_ACCESS_PUBLIC -template = "blog/discover.html" - - -async def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - if profile is not None: - __, entities_own, entities_roster = await self.host.bridge_call( - "disco_find_by_features", - [], - [("pubsub", "pep")], - True, - False, - True, - True, - True, - profile, - ) - entities = template_data["disco_entities"] = ( - list(entities_own.keys()) + list(entities_roster.keys()) - ) - entities_url = template_data["entities_url"] = {} - identities = self.host.get_session_data( - request, session_iface.IWebSession - ).identities - d_list = {} - for entity_jid_s in entities: - entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url( - entity_jid_s - ) - if entity_jid_s not in identities: - d_list[entity_jid_s] = self.host.bridge_call( - "identity_get", - entity_jid_s, - [], - True, - profile) - identities_data = await defer.DeferredList(d_list.values()) - entities_idx = list(d_list.keys()) - for idx, (success, identity_raw) in enumerate(identities_data): - entity_jid_s = entities_idx[idx] - if not success: - log.warning(_("Can't retrieve identity of {entity}") - .format(entity=entity_jid_s)) - else: - identities[entity_jid_s] = data_format.deserialise(identity_raw) - - template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit") - - -def on_data_post(self, request): - jid_str = self.get_posted_data(request, "jid") - try: - jid_ = jid.JID(jid_str) - except RuntimeError: - self.page_error(request, C.HTTP_BAD_REQUEST) - url = self.get_page_by_name("blog_view").get_url(jid_.full()) - self.http_redirect(request, url)
--- a/libervia/pages/blog/view/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -import alt_media_player - - -alt_media_player.install_if_needed()
--- a/libervia/pages/blog/view/atom.xml/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from sat.tools.common import uri -import time - -name = "blog_feed_atom" -access = C.PAGES_ACCESS_PUBLIC -template = "blog/atom.xml" - - -async def prepare_render(self, request): - request.setHeader("Content-Type", "application/atom+xml; charset=utf-8") - data = self.get_r_data(request) - service, node = data["service"], data.get("node") - self.check_cache( - request, C.CACHE_PUBSUB, service=service, node=node, short="microblog" - ) - data["show_comments"] = False - template_data = request.template_data - blog_page = self.get_page_by_name("blog_view") - await blog_page.prepare_render(self, request) - items = data["blog_items"]['items'] - - template_data["request_uri"] = self.host.get_ext_base_url( - request, request.path.decode("utf-8") - ) - template_data["xmpp_uri"] = uri.build_xmpp_uri( - "pubsub", subtype="microblog", path=service.full(), node=node - ) - blog_view = self.get_page_by_name("blog_view") - template_data["http_uri"] = self.host.get_ext_base_url( - request, blog_view.get_url(service.full(), node) - ) - if items: - template_data["updated"] = items[0]['updated'] - else: - template_data["updated"] = time.time()
--- a/libervia/pages/blog/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,381 +0,0 @@ -#!/usr/bin/env python3 - -import html -from typing import Any, Dict, Optional - -from sat.core.i18n import D_, _ -from sat.core.log import getLogger -from sat.tools.common import uri -from sat.tools.common import data_format -from sat.tools.common import regex -from sat.tools.common.template import safe -from twisted.web import server -from twisted.words.protocols.jabber import jid - -from libervia.server import utils -from libervia.server.constants import Const as C -from libervia.server.utils import SubPage - -log = getLogger(__name__) - -"""generic blog (with service/node provided)""" -name = 'blog_view' -template = "blog/articles.html" -uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'} - -URL_LIMIT_MARK = 90 # if canonical URL is longer than that, text will not be appended - - -def microblog_uri(self, uri_data): - args = [uri_data['path'], uri_data['node']] - if 'item' in uri_data: - args.extend(['id', uri_data['item']]) - return self.get_url(*args) - -def parse_url(self, request): - """URL is /[service]/[node]/[filter_keyword]/[item]|[other] - - if [node] is '@', default namespace is used - if a value is unset, default one will be used - keyword can be one of: - id: next value is a item id - tag: next value is a blog tag - """ - data = self.get_r_data(request) - - try: - service = self.next_path(request) - except IndexError: - data['service'] = '' - else: - try: - data["service"] = jid.JID(service) - except Exception: - log.warning(_("bad service entered: {}").format(service)) - self.page_error(request, C.HTTP_BAD_REQUEST) - - try: - node = self.next_path(request) - except IndexError: - node = '@' - data['node'] = '' if node == '@' else node - - try: - filter_kw = data['filter_keyword'] = self.next_path(request) - except IndexError: - filter_kw = '@' - else: - if filter_kw == '@': - # No filter, this is used when a subpage is needed, notably Atom feed - pass - elif filter_kw == 'id': - try: - data['item'] = self.next_path(request) - except IndexError: - self.page_error(request, C.HTTP_BAD_REQUEST) - # we get one more argument in case text has been added to have a nice URL - try: - self.next_path(request) - except IndexError: - pass - elif filter_kw == 'tag': - try: - data['tag'] = self.next_path(request) - except IndexError: - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - # invalid filter keyword - log.warning(_("invalid filter keyword: {filter_kw}").format( - filter_kw=filter_kw)) - self.page_error(request, C.HTTP_BAD_REQUEST) - - # if URL is parsed here, we'll have atom.xml available and we need to - # add the link to the page - atom_url = self.get_url_by_path( - SubPage('blog_view'), - service, - node, - filter_kw, - SubPage('blog_feed_atom'), - ) - request.template_data['atom_url'] = atom_url - request.template_data.setdefault('links', []).append({ - "href": atom_url, - "type": "application/atom+xml", - "rel": "alternate", - "title": "{service}'s blog".format(service=service)}) - - -def add_breadcrumb(self, request, breadcrumbs): - data = self.get_r_data(request) - breadcrumbs.append({ - "label": D_("Feed"), - "url": self.get_url(data["service"].full(), data.get("node", "@")) - }) - if "item" in data: - breadcrumbs.append({ - "label": D_("Post"), - }) - - -async def append_comments( - self, - request: server.Request, - blog_items: dict, - profile: str, - _seen: Optional[set] = None -) -> None: - """Recursively download and append comments of items - - @param blog_items: items data - @param profile: Libervia profile - @param _seen: used to avoid infinite recursion. For internal use only - """ - if _seen is None: - _seen = set() - await self.fill_missing_identities( - request, [i['author_jid'] for i in blog_items['items']]) - extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION} - if not self.use_cache(request): - extra[C.KEY_USE_CACHE] = False - for blog_item in blog_items['items']: - for comment_data in blog_item['comments']: - service = comment_data['service'] - node = comment_data['node'] - service_node = (service, node) - if service_node in _seen: - log.warning( - f"Items from {node!r} at {service} have already been retrieved, " - "there is a recursion at this service" - ) - comment_data["items"] = [] - continue - else: - _seen.add(service_node) - try: - comments_data = await self.host.bridge_call('mb_get', - service, - node, - C.NO_LIMIT, - [], - data_format.serialise( - extra - ), - profile) - except Exception as e: - log.warning( - _("Can't get comments at {node} (service: {service}): {msg}").format( - service=service, - node=node, - msg=e)) - comment_data['items'] = [] - continue - - comments = data_format.deserialise(comments_data) - if comments is None: - log.error(f"Comments should not be None: {comment_data}") - comment_data["items"] = [] - continue - comment_data['items'] = comments['items'] - await append_comments(self, request, comments, profile, _seen=_seen) - -async def get_blog_items( - self, - request: server.Request, - service: jid.JID, - node: str, - item_id, - extra: Dict[str, Any], - profile: str -) -> dict: - try: - if item_id: - items_id = [item_id] - else: - items_id = [] - if not self.use_cache(request): - extra[C.KEY_USE_CACHE] = False - blog_data = await self.host.bridge_call('mb_get', - service.userhost(), - node, - C.NO_LIMIT, - items_id, - data_format.serialise(extra), - profile) - except Exception as e: - # FIXME: need a better way to test errors in bridge errback - if "forbidden" in str(e): - self.page_error(request, 401) - else: - log.warning(_("can't retrieve blog for [{service}]: {msg}".format( - service = service.userhost(), msg=e))) - blog_data = {"items": []} - else: - blog_data = data_format.deserialise(blog_data) - - return blog_data - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - page_max = data.get("page_max", 10) - # if the comments are not explicitly hidden, we show them - service, node, item_id, show_comments = ( - data.get('service', ''), - data.get('node', ''), - data.get('item'), - data.get('show_comments', True) - ) - profile = self.get_profile(request) - if profile is None: - profile = C.SERVICE_PROFILE - profile_connected = False - else: - profile_connected = True - - ## pagination/filtering parameters - if item_id: - extra = {} - else: - extra = self.get_pubsub_extra(request, page_max=page_max) - tag = data.get('tag') - if tag: - extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag - self.handle_search(request, extra) - - ## main data ## - # we get data from backend/XMPP here - blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile) - - ## navigation ## - # no let's fill service, node and pagination URLs - if 'service' not in template_data: - template_data['service'] = service - if 'node' not in template_data: - template_data['node'] = node - target_profile = template_data.get('target_profile') - - if blog_items: - if item_id: - template_data["previous_page_url"] = self.get_url( - service.full(), - node, - before=item_id, - page_max=1 - ) - template_data["next_page_url"] = self.get_url( - service.full(), - node, - after=item_id, - page_max=1 - ) - blog_items["rsm"] = { - "last": item_id, - "first": item_id, - } - blog_items["complete"] = False - else: - self.set_pagination(request, blog_items) - else: - if item_id: - # if item id has been specified in URL and it's not found, - # we must return an error - self.page_error(request, C.HTTP_NOT_FOUND) - - ## identities ## - # identities are used to show nice nickname or avatars - await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']]) - - ## Comments ## - # if comments are requested, we need to take them - if show_comments: - await append_comments(self, request, blog_items, profile) - - ## URLs ## - # We will fill items_http_uri and tags_http_uri in template_data with suitable urls - # if we know the profile, we use it instead of service + blog (nicer url) - if target_profile is None: - blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id') - blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag') - else: - blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])]) - blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])]) - # we also set the background image if specified by user - bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile']) - if bg_img: - template_data['dynamic_style'] = safe(""" - :root { - --bg-img: url("%s"); - } - """ % html.escape(bg_img, True)) - - template_data['blog_items'] = data['blog_items'] = blog_items - if request.args.get(b'reverse') == ['1']: - template_data['blog_items'].items.reverse() - template_data['items_http_uri'] = items_http_uri = {} - template_data['tags_http_uri'] = tags_http_uri = {} - - - for item in blog_items['items']: - blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])]) - if len(blog_canonical_url) > URL_LIMIT_MARK: - blog_url = blog_canonical_url - elif '-' not in item['id']: - # we add text from title or body at the end of URL - # to make it more human readable - # we do it only if there is no "-", as a "-" probably means that - # item's id is already user friendly. - # TODO: to be removed, this is only kept for a transition period until - # user friendly item IDs are more common. - text = regex.url_friendly_text(item.get('title', item['content'])) - if text: - blog_url = blog_canonical_url + '/' + text - else: - blog_url = blog_canonical_url - else: - blog_url = blog_canonical_url - - items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url) - for tag in item['tags']: - if tag not in tags_http_uri: - tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)]) - tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url) - - # if True, page should display a comment box - template_data['allow_commenting'] = data.get('allow_commenting', profile_connected) - - # last but not least, we add a xmpp: link to the node - uri_args = {'path': service.full()} - if node: - uri_args['node'] = node - if item_id: - uri_args['item'] = item_id - template_data['xmpp_uri'] = uri.build_xmpp_uri( - 'pubsub', subtype='microblog', **uri_args - ) - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if profile is None: - self.page_error(request, C.HTTP_FORBIDDEN) - type_ = self.get_posted_data(request, 'type') - if type_ == 'comment': - service, node, body = self.get_posted_data(request, ('service', 'node', 'body')) - - if not body: - self.page_error(request, C.HTTP_BAD_REQUEST) - comment_data = {"content_rich": body} - try: - await self.host.bridge_call('mb_send', - service, - node, - data_format.serialise(comment_data), - profile) - except Exception as e: - if "forbidden" in str(e): - self.page_error(request, 401) - else: - raise e - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/calendar/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -from browser import document, window -from browser.timer import set_interval - -calendar_start = window.calendar_start - - -def update_current_time_line(): - now = window.Date.new() - - # Calculate the position of the current-time-line - now_ts = now.getTime() / 1000 - minutes_passed = (now_ts - calendar_start) / 60 - - new_top = minutes_passed + 15 - - # Update the current-time-line position and make it visible - current_time_line = document["current-time-line"] - current_time_line.style.top = f"{new_top}px" - current_time_line.hidden = False - -# Initial update -update_current_time_line() - -# Update the current-time-line every minute -set_interval(update_current_time_line, 60 * 1000)
--- a/libervia/pages/calendar/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import data_format -from twisted.internet import defer -import datetime -import time -from dateutil import tz - -from libervia.server.constants import Const as C - -log = getLogger(__name__) - - -name = "calendar" -access = C.PAGES_ACCESS_PROFILE -template = "calendar/daily.html" - - -async def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - # template_data["url_event_new"] = self.get_sub_page_url(request, "event_new") - if profile is not None: - try: - events = data_format.deserialise( - await self.host.bridge_call("events_get", "", "", [], "", profile), - type_check=list - ) - except Exception as e: - log.warning(_("Can't get events list for {profile}: {reason}").format( - profile=profile, reason=e)) - else: - template_data["events"] = events - - tz_name = template_data["tz_name"] = time.tzname[0] - local_tz = tz.tzlocal() - today_local = datetime.datetime.now(local_tz).date() - calendar_start = template_data["calendar_start"] = datetime.datetime.combine( - today_local, datetime.time.min, tzinfo=local_tz - ).timestamp() - calendar_end = template_data["calendar_end"] = datetime.datetime.combine( - today_local, datetime.time.max, tzinfo=local_tz - ).timestamp() - self.expose_to_scripts( - request, - calendar_start=calendar_start, - calendar_end=calendar_end, - tz_name=tz_name, - )
--- a/libervia/pages/calls/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,513 +0,0 @@ -import json -import re - -from bridge import AsyncBridge as Bridge -from browser import aio, console as log, document, timer, window -import errors -import loading - -log.warning = log.warn -profile = window.profile or "" -bridge = Bridge() -GATHER_TIMEOUT = 10000 - - -class WebRTCCall: - - def __init__(self): - self.reset_instance() - - def reset_instance(self): - """Inits or resets the instance variables to their default state.""" - self._peer_connection = None - self._media_types = None - self.sid = None - self.local_candidates = None - self.remote_stream = None - self.candidates_buffer = { - "audio": {"candidates": []}, - "video": {"candidates": []}, - } - self.media_candidates = {} - self.candidates_gathered = aio.Future() - - @property - def media_types(self): - if self._media_types is None: - raise Exception("self._media_types should not be None!") - return self._media_types - - def get_sdp_mline_index(self, media_type): - """Gets the sdpMLineIndex for a given media type. - - @param media_type: The type of the media. - """ - for index, m_type in self.media_types.items(): - if m_type == media_type: - return index - raise ValueError(f"Media type '{media_type}' not found") - - def extract_pwd_ufrag(self, sdp): - """Retrieves ICE password and user fragment for SDP offer. - - @param sdp: The Session Description Protocol offer string. - """ - ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) - pwd_line = re.search(r"ice-pwd:(\S+)", sdp) - - if ufrag_line and pwd_line: - return ufrag_line.group(1), pwd_line.group(1) - else: - log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") - raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") - - def extract_fingerprint_data(self, sdp): - """Retrieves fingerprint data from an SDP offer. - - @param sdp: The Session Description Protocol offer string. - @return: A dictionary containing the fingerprint data. - """ - fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp) - if fingerprint_line: - algorithm, fingerprint = fingerprint_line.groups() - fingerprint_data = { - "hash": algorithm, - "fingerprint": fingerprint - } - - setup_line = re.search(r"a=setup:(\S+)", sdp) - if setup_line: - setup = setup_line.group(1) - fingerprint_data["setup"] = setup - - return fingerprint_data - else: - raise ValueError("fingerprint should not be missing") - - def parse_ice_candidate(self, candidate_string): - """Parses the ice candidate string. - - @param candidate_string: The ice candidate string to be parsed. - """ - pattern = re.compile( - r"candidate:(?P<foundation>\S+) (?P<component_id>\d+) (?P<transport>\S+) " - r"(?P<priority>\d+) (?P<address>\S+) (?P<port>\d+) typ " - r"(?P<type>\S+)(?: raddr (?P<rel_addr>\S+) rport " - r"(?P<rel_port>\d+))?(?: generation (?P<generation>\d+))?" - ) - match = pattern.match(candidate_string) - if match: - candidate_dict = match.groupdict() - - # Apply the correct types to the dictionary values - candidate_dict["component_id"] = int(candidate_dict["component_id"]) - candidate_dict["priority"] = int(candidate_dict["priority"]) - candidate_dict["port"] = int(candidate_dict["port"]) - - if candidate_dict["rel_port"]: - candidate_dict["rel_port"] = int(candidate_dict["rel_port"]) - - if candidate_dict["generation"]: - candidate_dict["generation"] = candidate_dict["generation"] - - # Remove None values - return {k: v for k, v in candidate_dict.items() if v is not None} - else: - log.warning(f"can't parse candidate: {candidate_string!r}") - return None - - def build_ice_candidate(self, parsed_candidate): - """Builds ICE candidate - - @param parsed_candidate: Dictionary containing parsed ICE candidate - """ - base_format = ( - "candidate:{foundation} {component_id} {transport} {priority} " - "{address} {port} typ {type}" - ) - - if ((parsed_candidate.get('rel_addr') - and parsed_candidate.get('rel_port'))): - base_format += " raddr {rel_addr} rport {rel_port}" - - if parsed_candidate.get('generation'): - base_format += " generation {generation}" - - return base_format.format(**parsed_candidate) - - def on_ice_candidate(self, event): - """Handles ICE candidate event - - @param event: Event containing the ICE candidate - """ - log.debug(f"on ice candidate {event.candidate=}") - if event.candidate and event.candidate.candidate: - window.last_event = event - parsed_candidate = self.parse_ice_candidate(event.candidate.candidate) - if parsed_candidate is None: - return - try: - media_type = self.media_types[event.candidate.sdpMLineIndex] - except (TypeError, IndexError): - log.error( - f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" - ) - return - self.media_candidates.setdefault(media_type, []).append(parsed_candidate) - log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") - else: - log.debug("All ICE candidates gathered") - - def _set_media_types(self, offer): - """Sets media types from offer SDP - - @param offer: RTC session description containing the offer - """ - sdp_lines = offer.sdp.splitlines() - media_types = {} - mline_index = 0 - - for line in sdp_lines: - if line.startswith("m="): - media_types[mline_index] = line[2:line.find(" ")] - mline_index += 1 - - self._media_types = media_types - - def on_ice_gathering_state_change(self, event): - """Handles ICE gathering state change - - @param event: Event containing the ICE gathering state change - """ - connection = event.target - log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") - if connection.iceGatheringState == "complete": - log.info("ICE candidates gathering done") - self.candidates_gathered.set_result(None) - - async def _create_peer_connection( - self, - ): - """Creates peer connection""" - if self._peer_connection is not None: - raise Exception("create_peer_connection can't be called twice!") - - external_disco = json.loads(await bridge.external_disco_get("")) - ice_servers = [] - - for server in external_disco: - ice_server = {} - if server["type"] == "stun": - ice_server["urls"] = f"stun:{server['host']}:{server['port']}" - elif server["type"] == "turn": - ice_server["urls"] = ( - f"turn:{server['host']}:{server['port']}?transport={server['transport']}" - ) - ice_server["username"] = server["username"] - ice_server["credential"] = server["password"] - ice_servers.append(ice_server) - - rtc_configuration = {"iceServers": ice_servers} - - peer_connection = window.RTCPeerConnection.new(rtc_configuration) - peer_connection.addEventListener("track", self.on_track) - peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed) - peer_connection.addEventListener("icecandidate", self.on_ice_candidate) - peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) - - self._peer_connection = peer_connection - window.pc = self._peer_connection - - async def _get_user_media( - self, - audio: bool = True, - video: bool = True - ): - """Gets user media - - @param audio: True if an audio flux is required - @param video: True if a video flux is required - """ - media_constraints = {'audio': audio, 'video': video} - local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints) - document["local_video"].srcObject = local_stream - - for track in local_stream.getTracks(): - self._peer_connection.addTrack(track) - - async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): - """Get ICE candidates and wait to have them all before returning them - - @param is_initiator: Boolean indicating if the user is the initiator of the connection - @param remote_candidates: Remote ICE candidates, if any - """ - if self._peer_connection is None: - raise Exception("The peer connection must be created before gathering ICE candidates!") - - self.media_candidates.clear() - gather_timeout = timer.set_timeout( - lambda: self.candidates_gathered.set_exception( - errors.TimeoutError("ICE gathering time out") - ), - GATHER_TIMEOUT - ) - - if is_initiator: - offer = await self._peer_connection.createOffer() - self._set_media_types(offer) - await self._peer_connection.setLocalDescription(offer) - else: - answer = await self._peer_connection.createAnswer() - self._set_media_types(answer) - await self._peer_connection.setLocalDescription(answer) - - if not is_initiator: - log.debug(self._peer_connection.localDescription.sdp) - await self.candidates_gathered - log.debug(self._peer_connection.localDescription.sdp) - timer.clear_timeout(gather_timeout) - ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp) - return { - "ufrag": ufrag, - "pwd": pwd, - "candidates": self.media_candidates, - } - - def on_action_new( - self, action_data_s: str, action_id: str, security_limit: int, profile: str - ) -> None: - """Called when a call is received - - @param action_data_s: Action data serialized - @param action_id: Unique identifier for the action - @param security_limit: Security limit for the action - @param profile: Profile associated with the action - """ - action_data = json.loads(action_data_s) - if action_data.get("type") != "call": - return - peer_jid = action_data["from_jid"] - log.info( - f"{peer_jid} wants to start a call ({action_data['sub_type']})" - ) - if self.sid is not None: - log.warning( - f"already in a call ({self.sid}), can't receive a new call from " - f"{peer_jid}" - ) - return - self.sid = action_data["session_id"] - log.debug(f"Call SID: {self.sid}") - - # Answer the call - offer_sdp = action_data["sdp"] - aio.run(self.answer_call(offer_sdp, action_id)) - - def _on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: - """Called when we have received answer SDP from responder - - @param session_id: Session identifier - @param sdp: Session Description Protocol data - @param profile: Profile associated with the action - """ - aio.run(self.on_call_accepted(session_id, sdp, profile)) - - def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None: - """Call has been terminated - - @param session_id: Session identifier - @param data_s: Serialised additional data on why the call has ended - @param profile: Profile associated - """ - if self.sid is None: - log.debug("there are no calls in progress") - return - if session_id != self.sid: - log.debug( - f"ignoring call_ended not linked to our call ({self.sid}): {session_id}" - ) - return - aio.run(self.end_call()) - - async def on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: - """Call has been accepted, connection can be established - - @param session_id: Session identifier - @param sdp: Session Description Protocol data - @param profile: Profile associated - """ - if self.sid != session_id: - log.debug( - f"Call ignored due to different session ID ({self.sid=} {session_id=})" - ) - return - await self._peer_connection.setRemoteDescription({ - "type": "answer", - "sdp": sdp - }) - await self.on_ice_candidates_new(self.candidates_buffer) - self.candidates_buffer.clear() - - def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: - """Called when new ICE candidates are received - - @param sid: Session identifier - @param candidates_s: ICE candidates serialized - @param profile: Profile associated with the action - """ - if sid != self.sid: - log.debug( - f"ignoring peer ice candidates for {sid=} ({self.sid=})." - ) - return - candidates = json.loads(candidates_s) - aio.run(self.on_ice_candidates_new(candidates)) - - async def on_ice_candidates_new(self, candidates: dict) -> None: - """Called when new ICE canidates are received from peer - - @param candidates: Dictionary containing new ICE candidates - """ - log.debug(f"new peer candidates received: {candidates}") - if ( - self._peer_connection is None - or self._peer_connection.remoteDescription is None - ): - for media_type in ("audio", "video"): - media_candidates = candidates.get(media_type) - if media_candidates: - buffer = self.candidates_buffer[media_type] - buffer["candidates"].extend(media_candidates["candidates"]) - return - for media_type, ice_data in candidates.items(): - for candidate in ice_data["candidates"]: - candidate_sdp = self.build_ice_candidate(candidate) - try: - sdp_mline_index = self.get_sdp_mline_index(media_type) - except Exception as e: - log.warning(e) - continue - ice_candidate = window.RTCIceCandidate.new({ - "candidate": candidate_sdp, - "sdpMLineIndex": sdp_mline_index - } - ) - await self._peer_connection.addIceCandidate(ice_candidate) - - def on_track(self, event): - """New track has been received from peer - - @param event: Event associated with the new track - """ - if event.streams and event.streams[0]: - remote_stream = event.streams[0] - document["remote_video"].srcObject = remote_stream - else: - if self.remote_stream is None: - self.remote_stream = window.MediaStream.new() - document["remote_video"].srcObject = self.remote_stream - self.remote_stream.addTrack(event.track) - - document["call_btn"].classList.add("is-hidden") - document["hangup_btn"].classList.remove("is-hidden") - - def on_negotiation_needed(self, event) -> None: - log.debug(f"on_negotiation_needed {event=}") - # TODO - - async def answer_call(self, offer_sdp: str, action_id: str): - """We respond to the call""" - log.debug("answering call") - await self._create_peer_connection() - - await self._peer_connection.setRemoteDescription({ - "type": "offer", - "sdp": offer_sdp - }) - await self.on_ice_candidates_new(self.candidates_buffer) - self.candidates_buffer.clear() - await self._get_user_media() - - # Gather local ICE candidates - local_ice_data = await self._gather_ice_candidates(False) - self.local_candidates = local_ice_data["candidates"] - - await bridge.action_launch( - action_id, - json.dumps({ - "sdp": self._peer_connection.localDescription.sdp, - }) - ) - - async def make_call(self, audio: bool = True, video: bool = True) -> None: - """Start a WebRTC call - - @param audio: True if an audio flux is required - @param video: True if a video flux is required - """ - await self._create_peer_connection() - await self._get_user_media(audio, video) - await self._gather_ice_candidates(True) - callee_jid = document["callee_jid"].value - - call_data = { - "sdp": self._peer_connection.localDescription.sdp - } - log.info(f"calling {callee_jid!r}") - self.sid = await bridge.call_start( - callee_jid, - json.dumps(call_data) - ) - log.debug(f"Call SID: {self.sid}") - - async def end_call(self) -> None: - """Stop streaming and clean instance""" - document["hangup_btn"].classList.add("is-hidden") - document["call_btn"].classList.remove("is-hidden") - if self._peer_connection is None: - log.debug("There is currently no call to end.") - else: - self._peer_connection.removeEventListener("track", self.on_track) - self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed) - self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate) - self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) - - local_video = document["local_video"] - remote_video = document["remote_video"] - if local_video.srcObject: - for track in local_video.srcObject.getTracks(): - track.stop() - if remote_video.srcObject: - for track in remote_video.srcObject.getTracks(): - track.stop() - - self._peer_connection.close() - self.reset_instance() - - async def hand_up(self) -> None: - """Terminate the call""" - session_id = self.sid - await self.end_call() - await bridge.call_end( - session_id, - "" - ) - - -webrtc_call = WebRTCCall() - -document["call_btn"].bind( - "click", - lambda __: aio.run(webrtc_call.make_call()) -) -document["hangup_btn"].bind( - "click", - lambda __: aio.run(webrtc_call.hand_up()) -) - -bridge.register_signal("action_new", webrtc_call.on_action_new) -bridge.register_signal("call_accepted", webrtc_call._on_call_accepted) -bridge.register_signal("call_ended", webrtc_call._on_call_ended) -bridge.register_signal("ice_candidates_new", webrtc_call._on_ice_candidates_new) - -loading.remove_loading_screen()
--- a/libervia/pages/calls/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import data_format -from twisted.internet import defer -import datetime -import time -from dateutil import tz - -from libervia.server.constants import Const as C - -log = getLogger(__name__) - - -name = "calls" -access = C.PAGES_ACCESS_PROFILE -template = "call/call.html"
--- a/libervia/pages/chat/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 - -from sat.core.i18n import _ -from twisted.internet import defer -from sat.core.log import getLogger -from sat.tools.common import data_objects -from sat.tools.common import data_format -from twisted.words.protocols.jabber import jid -from libervia.server.constants import Const as C -from libervia.server import session_iface - - -log = getLogger(__name__) - -name = "chat" -access = C.PAGES_ACCESS_PROFILE -template = "chat/chat.html" -dynamic = True - - -def parse_url(self, request): - rdata = self.get_r_data(request) - - try: - target_jid_s = self.next_path(request) - except IndexError: - # not chat jid, we redirect to jid selection page - self.page_redirect("chat_select", request) - - try: - target_jid = jid.JID(target_jid_s) - if not target_jid.user: - raise ValueError(_("invalid jid for chat (no local part)")) - except Exception as e: - log.warning( - _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid, msg=e) - ) - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - rdata["target"] = target_jid - - -@defer.inlineCallbacks -def prepare_render(self, request): - # FIXME: bug on room filtering (currently display messages from all rooms) - session = self.host.get_session_data(request, session_iface.IWebSession) - template_data = request.template_data - rdata = self.get_r_data(request) - target_jid = rdata["target"] - profile = session.profile - profile_jid = session.jid - - disco = yield self.host.bridge_call("disco_infos", target_jid.host, "", True, profile) - if "conference" in [i[0] for i in disco[1]]: - chat_type = C.CHAT_GROUP - join_ret = yield self.host.bridge_call( - "muc_join", target_jid.userhost(), "", "", profile - ) - (already_joined, - room_jid_s, - occupants, - user_nick, - room_subject, - room_statuses, - __) = join_ret - template_data["subject"] = room_subject - template_data["room_statuses"] = room_statuses - own_jid = jid.JID(room_jid_s) - own_jid.resource = user_nick - else: - chat_type = C.CHAT_ONE2ONE - own_jid = profile_jid - rdata["chat_type"] = chat_type - template_data["own_jid"] = own_jid - - self.register_signal(request, "message_new") - history = yield self.host.bridge_call( - "history_get", - profile_jid.userhost(), - target_jid.userhost(), - 20, - True, - {}, - profile, - ) - authors = {m[2] for m in history} - identities = session.identities - for author in authors: - id_raw = yield self.host.bridge_call( - "identity_get", author, [], True, profile) - identities[author] = data_format.deserialise(id_raw) - - template_data["messages"] = data_objects.Messages(history) - rdata['identities'] = identities - template_data["target_jid"] = target_jid - template_data["chat_type"] = chat_type - - -def on_data(self, request, data): - session = self.host.get_session_data(request, session_iface.IWebSession) - rdata = self.get_r_data(request) - target = rdata["target"] - data_type = data.get("type", "") - if data_type == "msg": - message = data["body"] - mess_type = ( - C.MESS_TYPE_GROUPCHAT - if rdata["chat_type"] == C.CHAT_GROUP - else C.MESS_TYPE_CHAT - ) - log.debug("message received: {}".format(message)) - self.host.bridge_call( - "message_send", - target.full(), - {"": message}, - {}, - mess_type, - "", - session.profile, - ) - else: - log.warning("unknown message type: {type}".format(type=data_type))
--- a/libervia/pages/chat/select/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import _ -from libervia.server.constants import Const as C -from twisted.internet import defer -from twisted.words.protocols.jabber import jid -from sat.tools.common import data_objects -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "chat_select" -access = C.PAGES_ACCESS_PROFILE -template = "chat/select.html" - - -@defer.inlineCallbacks -def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - rooms = template_data["rooms"] = [] - bookmarks = yield self.host.bridge_call("bookmarks_list", "muc", "all", profile) - for bm_values in list(bookmarks.values()): - for room_jid, room_data in bm_values.items(): - url = self.get_page_by_name("chat").get_url(room_jid) - rooms.append(data_objects.Room(room_jid, name=room_data.get("name"), url=url)) - rooms.sort(key=lambda r: r.name) - - -@defer.inlineCallbacks -def on_data_post(self, request): - jid_ = self.get_posted_data(request, "jid") - if "@" not in jid_: - profile = self.get_profile(request) - service = yield self.host.bridge_call("muc_get_service", "", profile) - if service: - muc_jid = jid.JID(service) - muc_jid.user = jid_ - jid_ = muc_jid.full() - else: - log.warning(_("Invalid jid received: {jid}".format(jid=jid_))) - defer.returnValue(C.POST_NO_CONFIRM) - url = self.get_page_by_name("chat").get_url(jid_) - self.http_redirect(request, url)
--- a/libervia/pages/embed/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 - -from sat.core.log import getLogger -from sat.core import exceptions -from libervia.server.constants import Const as C - -log = getLogger(__name__) - -name = "embed_app" -template = "embed/embed.html" - - -def parse_url(self, request): - self.get_path_args(request, ["app_name"], min_args=1) - data = self.get_r_data(request) - app_name = data["app_name"] - try: - app_data = self.vhost_root.libervia_apps[app_name] - except KeyError: - self.page_error(request, C.HTTP_BAD_REQUEST) - template_data = request.template_data - template_data['full_screen_body'] = True - try: - template_data["target_url"] = app_data["url_prefix"] - except KeyError: - raise exceptions.InternalError(f'"url_prefix" is missing for {app_name!r}')
--- a/libervia/pages/events/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,52 +0,0 @@ -from browser import DOMNode, document, aio -from javascript import JSON -from bridge import AsyncBridge as Bridge, BridgeException -import dialog - -bridge = Bridge() - - -async def on_delete(evt): - evt.stopPropagation() - evt.preventDefault() - target = evt.currentTarget - item_elt = DOMNode(target.closest('.item')) - item_elt.classList.add("selected_for_deletion") - item = JSON.parse(item_elt.dataset.item) - confirmed = await dialog.Confirm( - f"Event {item['name']!r} will be deleted, are you sure?", - ok_label="delete", - ).ashow() - - if not confirmed: - item_elt.classList.remove("selected_for_deletion") - return - - try: - await bridge.interest_retract("", item['interest_id']) - except BridgeException as e: - dialog.notification.show( - f"Can't remove list {item['name']!r} from personal interests: {e}", - "error" - ) - else: - print(f"{item['name']!r} removed successfuly from list of interests") - item_elt.classList.add("state_deleted") - item_elt.bind("transitionend", lambda evt: item_elt.remove()) - if item.get("creator", False): - try: - await bridge.ps_node_delete( - item['service'], - item['node'], - ) - except BridgeException as e: - dialog.notification.show( - f"Error while deleting {item['name']!r}: {e}", - "error" - ) - else: - dialog.notification.show(f"{item['name']!r} has been deleted") - - -for elt in document.select('.action_delete'): - elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- a/libervia/pages/events/admin/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.words.protocols.jabber import jid -from sat.tools.common.template import safe -from sat.tools.common import data_format -from sat.core.i18n import _, D_ -from sat.core.log import getLogger -import time -import html -import math -import re - -name = "event_admin" -label = D_("Event Administration") -access = C.PAGES_ACCESS_PROFILE -template = "event/admin.html" -log = getLogger(__name__) -REG_EMAIL_RE = re.compile(C.REG_EMAIL_RE, re.IGNORECASE) - - -def parse_url(self, request): - self.get_path_args( - request, - ("event_service", "event_node", "event_id"), - min_args=2, - event_service="@jid", - event_id="", - ) - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - - ## Event ## - - event_service = template_data["event_service"] = data["event_service"] - event_node = template_data["event_node"] = data["event_node"] - event_id = template_data["event_id"] = data["event_id"] - profile = self.get_profile(request) - event_timestamp, event_data = await self.host.bridge_call( - "eventGet", - event_service.userhost() if event_service else "", - event_node, - event_id, - profile, - ) - try: - background_image = event_data.pop("background-image") - except KeyError: - pass - else: - template_data["dynamic_style"] = safe( - """ - html { - background-image: url("%s"); - background-size: 15em; - } - """ - % html.escape(background_image, True) - ) - template_data["event"] = event_data - invitees = await self.host.bridge_call( - "event_invitees_list", - event_data["invitees_service"], - event_data["invitees_node"], - profile, - ) - template_data["invitees"] = invitees - invitees_guests = 0 - for invitee_data in invitees.values(): - if invitee_data.get("attend", "no") == "no": - continue - try: - invitees_guests += int(invitee_data.get("guests", 0)) - except ValueError: - log.warning( - _("guests value is not valid: {invitee}").format(invitee=invitee_data) - ) - template_data["invitees_guests"] = invitees_guests - template_data["days_left"] = int( - math.ceil((event_timestamp - time.time()) / (60 * 60 * 24)) - ) - - ## Blog ## - - data["service"] = jid.JID(event_data["blog_service"]) - data["node"] = event_data["blog_node"] - data["allow_commenting"] = "simple" - - # we now need blog items, using blog common page - # this will fill the "items" template data - blog_page = self.get_page_by_name("blog_view") - await blog_page.prepare_render(self, request) - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if not profile: - log.error("got post data without profile") - self.page_error(request, C.HTTP_INTERNAL_ERROR) - type_ = self.get_posted_data(request, "type") - if type_ == "blog": - service, node, title, body, lang = self.get_posted_data( - request, ("service", "node", "title", "body", "language") - ) - - if not body.strip(): - self.page_error(request, C.HTTP_BAD_REQUEST) - data = {"content": body} - if title: - data["title"] = title - if lang: - data["language"] = lang - try: - comments = bool(self.get_posted_data(request, "comments").strip()) - except KeyError: - pass - else: - if comments: - data["allow_comments"] = True - - try: - await self.host.bridge_call( - "mb_send", service, node, data_format.serialise(data), profile) - except Exception as e: - if "forbidden" in str(e): - self.page_error(request, C.HTTP_FORBIDDEN) - else: - raise e - elif type_ == "event": - service, node, event_id, jids, emails = self.get_posted_data( - request, ("service", "node", "event_id", "jids", "emails") - ) - for invitee_jid_s in jids.split(): - try: - invitee_jid = jid.JID(invitee_jid_s) - except RuntimeError: - log.warning( - _("this is not a valid jid: {jid}").format(jid=invitee_jid_s) - ) - continue - await self.host.bridge_call( - "event_invite", invitee_jid.userhost(), service, node, event_id, profile - ) - for email_addr in emails.split(): - if not REG_EMAIL_RE.match(email_addr): - log.warning( - _("this is not a valid email address: {email}").format( - email=email_addr - ) - ) - continue - await self.host.bridge_call( - "event_invite_by_email", - service, - node, - event_id, - email_addr, - {}, - "", - "", - "", - "", - "", - "", - profile, - ) - - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/events/new/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.internet import defer -from sat.core.log import getLogger -from sat.tools.common import date_utils - -"""creation of new events""" - -name = "event_new" -access = C.PAGES_ACCESS_PROFILE -template = "event/create.html" -log = getLogger(__name__) - - -@defer.inlineCallbacks -def on_data_post(self, request): - request_data = self.get_r_data(request) - profile = self.get_profile(request) - title, location, body, date, main_img, bg_img = self.get_posted_data( - request, ("name", "location", "body", "date", "main_image", "bg_image") - ) - timestamp = date_utils.date_parse(date) - data = {"name": title, "description": body, "location": location} - - for value, var in ((main_img, "image"), (bg_img, "background-image")): - value = value.strip() - if not value: - continue - if not value.startswith("http"): - self.page_error(request, C.HTTP_BAD_REQUEST) - data[var] = value - data["register"] = C.BOOL_TRUE - node = yield self.host.bridge_call("event_create", timestamp, data, "", "", "", profile) - log.info("Event node created at {node}".format(node=node)) - - request_data["post_redirect_page"] = (self.get_page_by_name("event_admin"), "@", node) - defer.returnValue(C.POST_NO_CONFIRM)
--- a/libervia/pages/events/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import data_format -from twisted.internet import defer - -from libervia.server.constants import Const as C - -log = getLogger(__name__) - - -name = "events" -access = C.PAGES_ACCESS_PUBLIC -template = "event/overview.html" - - -async def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - template_data["url_event_new"] = self.get_sub_page_url(request, "event_new") - if profile is not None: - try: - events = data_format.deserialise( - await self.host.bridge_call("events_get", "", "", [], "", profile), - type_check=list - ) - except Exception as e: - log.warning(_("Can't get events list for {profile}: {reason}").format( - profile=profile, reason=e)) - else: - own_events = [] - other_events = [] - for event in events: - if C.bool(event.get("creator", C.BOOL_FALSE)): - own_events.append(event) - event["url"] = self.get_sub_page_url( - request, - "event_admin", - ) - else: - other_events.append(event) - event["url"] = self.get_sub_page_url( - request, - "event_rsvp", - ) - if "thumb_url" not in event and "image" in event: - event["thumb_url"] = event["image"] - - template_data["events"] = own_events + other_events
--- a/libervia/pages/events/rsvp/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _, D_ -from twisted.internet import defer -from twisted.words.protocols.jabber import jid -from sat.core.log import getLogger -from sat.tools.common.template import safe -import time -import html - -"""creation of new events""" - -name = "event_rsvp" -label = D_("Event Invitation") -access = C.PAGES_ACCESS_PROFILE -template = "event/invitation.html" -log = getLogger(__name__) - - -def parse_url(self, request): - self.get_path_args( - request, - ("event_service", "event_node", "event_id"), - min_args=2, - event_service="@jid", - event_id="", - ) - - -@defer.inlineCallbacks -def prepare_render(self, request): - template_data = request.template_data - data = self.get_r_data(request) - profile = self.get_profile(request) - - ## Event ## - - event_service = data["event_service"] - event_node = data["event_node"] - event_id = data["event_id"] - event_timestamp, event_data = yield self.host.bridge_call( - "eventGet", - event_service.userhost() if event_service else "", - event_node, - event_id, - profile, - ) - try: - background_image = event_data.pop("background-image") - except KeyError: - pass - else: - template_data["dynamic_style"] = safe( - """ - html { - background-image: url("%s"); - background-size: 15em; - } - """ - % html.escape(background_image, True) - ) - template_data["event"] = event_data - event_invitee_data = yield self.host.bridge_call( - "event_invitee_get", - event_data["invitees_service"], - event_data["invitees_node"], - '', - profile, - ) - template_data["invitee"] = event_invitee_data - template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24)) - - ## Blog ## - - data["service"] = jid.JID(event_data["blog_service"]) - data["node"] = event_data["blog_node"] - data["allow_commenting"] = "simple" - - # we now need blog items, using blog common page - # this will fill the "items" template data - blog_page = self.get_page_by_name("blog_view") - yield blog_page.prepare_render(self, request) - - -@defer.inlineCallbacks -def on_data_post(self, request): - type_ = self.get_posted_data(request, "type") - if type_ == "comment": - blog_page = self.get_page_by_name("blog_view") - yield blog_page.on_data_post(self, request) - elif type_ == "attendance": - profile = self.get_profile(request) - service, node, attend, guests = self.get_posted_data( - request, ("service", "node", "attend", "guests") - ) - data = {"attend": attend, "guests": guests} - yield self.host.bridge_call("event_invitee_set", service, node, data, profile) - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/events/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from twisted.internet import defer -from twisted.words.protocols.jabber import jid -from libervia.server import session_iface -from sat.tools.common import uri -from sat.tools.common.template import safe -import time -import html -from sat.core.log import getLogger - -name = "event_view" -access = C.PAGES_ACCESS_PROFILE -template = "event/invitation.html" -log = getLogger(__name__) - - -@defer.inlineCallbacks -def prepare_render(self, request): - template_data = request.template_data - guest_session = self.host.get_session_data(request, session_iface.ISATGuestSession) - try: - event_uri = guest_session.data["event_uri"] - except KeyError: - log.warning(_("event URI not found, can't render event page")) - self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE) - - data = self.get_r_data(request) - - ## Event ## - - event_uri_data = uri.parse_xmpp_uri(event_uri) - if event_uri_data["type"] != "pubsub": - self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE) - - event_service = template_data["event_service"] = jid.JID(event_uri_data["path"]) - event_node = template_data["event_node"] = event_uri_data["node"] - event_id = template_data["event_id"] = event_uri_data.get("item", "") - profile = self.get_profile(request) - event_timestamp, event_data = yield self.host.bridge_call( - "eventGet", event_service.userhost(), event_node, event_id, profile - ) - try: - background_image = event_data.pop("background-image") - except KeyError: - pass - else: - template_data["dynamic_style"] = safe( - """ - html { - background-image: url("%s"); - background-size: 15em; - } - """ - % html.escape(background_image, True) - ) - template_data["event"] = event_data - event_invitee_data = yield self.host.bridge_call( - "event_invitee_get", - event_data["invitees_service"], - event_data["invitees_node"], - '', - profile, - ) - template_data["invitee"] = event_invitee_data - template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24)) - - ## Blog ## - - data["service"] = jid.JID(event_data["blog_service"]) - data["node"] = event_data["blog_node"] - data["allow_commenting"] = "simple" - - # we now need blog items, using blog common page - # this will fill the "items" template data - blog_page = self.get_page_by_name("blog_view") - yield blog_page.prepare_render(self, request) - - -@defer.inlineCallbacks -def on_data_post(self, request): - type_ = self.get_posted_data(request, "type") - if type_ == "comment": - blog_page = self.get_page_by_name("blog_view") - yield blog_page.on_data_post(self, request) - elif type_ == "attendance": - profile = self.get_profile(request) - service, node, attend, guests = self.get_posted_data( - request, ("service", "node", "attend", "guests") - ) - data = {"attend": attend, "guests": guests} - yield self.host.bridge_call("event_invitee_set", service, node, data, profile) - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/files/list/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -from pathlib import Path -from sat.core.log import getLogger -from sat.tools.common import uri -from sat_frontends.bridge.bridge_frontend import BridgeException -from libervia.server.constants import Const as C -from libervia.server import session_iface -from libervia.server import pages_tools - -log = getLogger(__name__) -"""files handling pages""" - -name = "files_list" -access = C.PAGES_ACCESS_PROFILE -template = "file/overview.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="") - - -def add_breadcrumb(self, request, breadcrumbs): - data = self.get_r_data(request) - breadcrumbs.append({ - "label": data["service"], - "url": self.get_url(data["service"].full()), - "icon": "server", - }) - for idx, p in enumerate(data["path"]): - breadcrumbs.append({ - "label": p, - "url": self.get_url(data["service"].full(), *data["path"][:idx+1]), - "icon": "folder-open-empty", - }) - - -async def prepare_render(self, request): - data = self.get_r_data(request) - thumb_limit = data.get("thumb_limit", 400) - template_data = request.template_data - service, path_elts = data["service"], data["path"] - path = Path('/', *path_elts) - profile = self.get_profile(request) or C.SERVICE_PROFILE - session_data = self.host.get_session_data( - request, session_iface.IWebSession - ) - - try: - files_data = await self.host.bridge_call( - "fis_list", service.full(), str(path), {}, profile) - except BridgeException as e: - if e.condition == 'item-not-found': - log.debug( - f'"item-not-found" received for {path} at {service}, this may indicate ' - f'that the location is new') - files_data = [] - else: - raise e - for file_data in files_data: - try: - extra_raw = file_data["extra"] - except KeyError: - pass - else: - file_data["extra"] = json.loads(extra_raw) if extra_raw else {} - dir_path = path_elts + [file_data["name"]] - if file_data["type"] == C.FILE_TYPE_DIRECTORY: - page = self - elif file_data["type"] == C.FILE_TYPE_FILE: - page = self.get_page_by_name("files_view") - - # we set URL for the last thumbnail which has a size below thumb_limit - try: - thumbnails = file_data["extra"]["thumbnails"] - thumb = thumbnails[0] - for thumb_data in thumbnails: - if thumb_data["size"][0] > thumb_limit: - break - thumb = thumb_data - file_data["thumb_url"] = ( - thumb.get("url") - or os.path.join(session_data.cache_dir, thumb["filename"]) - ) - except (KeyError, IndexError): - pass - else: - raise ValueError( - "unexpected file type: {file_type}".format(file_type=file_data["type"]) - ) - file_data["url"] = page.get_url(service.full(), *dir_path) - - ## comments ## - comments_url = file_data.get("comments_url") - if comments_url: - parsed_url = uri.parse_xmpp_uri(comments_url) - comments_service = file_data["comments_service"] = parsed_url["path"] - comments_node = file_data["comments_node"] = parsed_url["node"] - try: - comments_count = file_data["comments_count"] = int( - file_data["comments_count"] - ) - except KeyError: - comments_count = None - if comments_count and data.get("retrieve_comments", False): - file_data["comments"] = await pages_tools.retrieve_comments( - self, comments_service, comments_node, profile=profile - ) - - # parent dir affiliation - # TODO: some caching? What if affiliation changes? - - try: - affiliations = await self.host.bridge_call( - "fis_affiliations_get", service.full(), "", str(path), profile - ) - except BridgeException as e: - if e.condition == 'item-not-found': - log.debug( - f'"item-not-found" received for {path} at {service}, this may indicate ' - f'that the location is new') - # FIXME: Q&D handling of empty dir (e.g. new directory/photos album) - affiliations = { - session_data.jid.userhost(): "owner" - } - if e.condition == "service-unavailable": - affiliations = {} - else: - raise e - - directory_affiliation = affiliations.get(session_data.jid.userhost()) - if directory_affiliation == "owner": - # we need to transtype dict items to str because with some bridges (D-Bus) - # we have a specific type which can't be exposed - self.expose_to_scripts( - request, - affiliations={str(e): str(a) for e, a in affiliations.items()} - ) - - template_data["directory_affiliation"] = directory_affiliation - template_data["files_data"] = files_data - template_data["path"] = path - self.expose_to_scripts( - request, - directory_affiliation=str(directory_affiliation), - files_service=service.full(), - files_path=str(path), - ) - if path_elts: - template_data["parent_url"] = self.get_url(service.full(), *path_elts[:-1])
--- a/libervia/pages/files/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.words.protocols.jabber import jid -from sat.core.log import getLogger - -log = getLogger(__name__) -"""files handling pages""" - -name = "files" -access = C.PAGES_ACCESS_PROFILE -template = "file/discover.html" - - -async def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - namespace = self.host.ns_map["fis"] - entities_services, entities_own, entities_roster = await self.host.bridge_call( - "disco_find_by_features", [namespace], [], False, True, True, True, False, profile - ) - tpl_service_entities = template_data["disco_service_entities"] = {} - tpl_own_entities = template_data["disco_own_entities"] = {} - tpl_roster_entities = template_data["disco_roster_entities"] = {} - entities_url = template_data["entities_url"] = {} - - # we store identities in dict of dict using category and type as keys - # this way it's easier to test category in the template - for tpl_entities, entities_map in ( - (tpl_service_entities, entities_services), - (tpl_own_entities, entities_own), - (tpl_roster_entities, entities_roster), - ): - for entity_str, entity_ids in entities_map.items(): - entity_jid = jid.JID(entity_str) - tpl_entities[entity_jid] = identities = {} - for cat, type_, name in entity_ids: - identities.setdefault(cat, {}).setdefault(type_, []).append(name) - entities_url[entity_jid] = self.get_page_by_name("files_list").get_url( - entity_jid.full() - ) - - -def on_data_post(self, request): - jid_str = self.get_posted_data(request, "jid") - try: - jid_ = jid.JID(jid_str) - except RuntimeError: - self.page_error(request, C.HTTP_BAD_REQUEST) - url = self.get_page_by_name("files_list").get_url(jid_.full()) - self.http_redirect(request, url)
--- a/libervia/pages/files/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from twisted.web import static -from libervia.server.utils import ProgressHandler -import tempfile -import os -import os.path -from sat.core.log import getLogger - -log = getLogger(__name__) -"""files handling pages""" - -name = "files_view" -access = C.PAGES_ACCESS_PROFILE - - -def parse_url(self, request): - self.get_path_args(request, ["service", "*path"], min_args=2, service="jid", path="") - - -def cleanup(__, tmp_dir, dest_path): - try: - os.unlink(dest_path) - except OSError: - log.warning(_("Can't remove temporary file {path}").format(path=dest_path)) - try: - os.rmdir(tmp_dir) - except OSError: - log.warning(_("Can't remove temporary directory {path}").format(path=tmp_dir)) - - -async def render(self, request): - data = self.get_r_data(request) - profile = self.get_profile(request) - service, path_elts = data["service"], data["path"] - basename = path_elts[-1] - dir_elts = path_elts[:-1] - dir_path = "/".join(dir_elts) - tmp_dir = tempfile.mkdtemp() - dest_path = os.path.join(tmp_dir, basename) - request.notifyFinish().addCallback(cleanup, tmp_dir, dest_path) - progress_id = await self.host.bridge_call( - "file_jingle_request", - service.full(), - dest_path, - basename, - "", - "", - {"path": dir_path}, - profile, - ) - log.debug("file requested") - await ProgressHandler(self.host, progress_id, profile).register() - log.debug("file downloaded") - self.delegate_to_resource(request, static.File(dest_path))
--- a/libervia/pages/forums/list/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from sat.core.log import getLogger -from sat.core.i18n import _ -from sat.tools.common import uri as xmpp_uri - -log = getLogger(__name__) -import json - -"""forum handling pages""" - -name = "forums" -access = C.PAGES_ACCESS_PUBLIC -template = "forum/overview.html" - - -def parse_url(self, request): - self.get_path_args( - request, - ["service", "node", "forum_key"], - service="@jid", - node="@", - forum_key="", - ) - - -def add_breadcrumb(self, request, breadcrumbs): - # we don't want breadcrumbs here as long as there is no forum discovery - # because it will be the landing page for forums activity until then - pass - - -def get_links(self, forums): - for forum in forums: - try: - uri = forum["uri"] - except KeyError: - pass - else: - uri = xmpp_uri.parse_xmpp_uri(uri) - service = uri["path"] - node = uri["node"] - forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node) - if "sub-forums" in forum: - get_links(self, forum["sub-forums"]) - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node, key = data["service"], data["node"], data["forum_key"] - profile = self.get_profile(request) or C.SERVICE_PROFILE - - try: - forums_raw = await self.host.bridge_call( - "forums_get", service.full() if service else "", node, key, profile - ) - except Exception as e: - log.warning(_("Can't retrieve forums: {msg}").format(msg=e)) - forums = [] - else: - forums = json.loads(forums_raw) - get_links(self, forums) - - template_data["forums"] = forums
--- a/libervia/pages/forums/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,3 +0,0 @@ - -def prepare_render(self, request): - self.page_redirect("forums", request, skip_parse_url=False)
--- a/libervia/pages/forums/topics/new/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,5 +0,0 @@ -import editor - - -editor.set_form_autosave("forum_topic_edit") -editor.BlogEditor("forum_topic_edit")
--- a/libervia/pages/forums/topics/new/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import D_ -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "forum_topic_new" -label = D_("New Topic") -access = C.PAGES_ACCESS_PROFILE -template = "blog/publish.html" - - -async def prepare_render(self, request): - template_data = request.template_data - template_data.update({ - "post_form_id": "forum_topic_edit", - "publish_title": D_("New Forum Topic"), - "title_label": D_("Topic"), - "title_required": True, - "body_label": D_("Message"), - "no_tabs": True, - }) - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if profile is None: - self.page_error(request, C.HTTP_FORBIDDEN) - rdata = self.get_r_data(request) - service = rdata["service"].full() if rdata["service"] else "" - node = rdata["node"] - title, body = self.get_posted_data(request, ("title", "body")) - title = title.strip() - body = body.strip() - if not title or not body: - self.page_error(request, C.HTTP_BAD_REQUEST) - topic_data = {"title": title, "content": body} - try: - await self.host.bridge_call( - "forum_topic_create", service, node, topic_data, profile - ) - except Exception as e: - if "forbidden" in str(e): - self.page_error(request, 401) - else: - raise e - - rdata["post_redirect_page"] = (self.get_page_by_name("forum_topics"), service, node)
--- a/libervia/pages/forums/topics/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _, D_ -from sat.core.log import getLogger -from sat.tools.common import uri as xmpp_uri -from sat.tools.common import data_format -from libervia.server import session_iface - -log = getLogger(__name__) - -name = "forum_topics" -label = D_("Forum Topics") -access = C.PAGES_ACCESS_PUBLIC -template = "forum/view_topics.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node"], 2, service="jid") - - -def add_breadcrumb(self, request, breadcrumbs): - data = self.get_r_data(request) - breadcrumbs.append({ - "label": label, - "url": self.get_url(data["service"].full(), data["node"]) - }) - - -async def prepare_render(self, request): - profile = self.get_profile(request) or C.SERVICE_PROFILE - data = self.get_r_data(request) - service, node = data["service"], data["node"] - request.template_data.update({"service": service, "node": node}) - template_data = request.template_data - page_max = data.get("page_max", 20) - extra = self.get_pubsub_extra(request, page_max=page_max) - topics, metadata = await self.host.bridge_call( - "forum_topics_get", - service.full(), - node, - extra, - profile - ) - metadata = data_format.deserialise(metadata) - self.set_pagination(request, metadata) - identities = self.host.get_session_data( - request, session_iface.IWebSession - ).identities - for topic in topics: - parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"]) - author = topic["author"] - topic["http_uri"] = self.get_page_by_name("forum_view").get_url( - parsed_uri["path"], parsed_uri["node"] - ) - if author not in identities: - id_raw = await self.host.bridge_call( - "identity_get", author, [], True, profile - ) - identities[topic["author"]] = data_format.deserialise(id_raw) - - template_data["topics"] = topics - template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new") - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if profile is None: - self.page_error(request, C.HTTP_FORBIDDEN) - type_ = self.get_posted_data(request, "type") - if type_ == "new_topic": - service, node, title, body = self.get_posted_data( - request, ("service", "node", "title", "body") - ) - - if not title or not body: - self.page_error(request, C.HTTP_BAD_REQUEST) - topic_data = {"title": title, "content": body} - try: - await self.host.bridge_call( - "forum_topic_create", service, node, topic_data, profile - ) - except Exception as e: - if "forbidden" in str(e): - self.page_error(request, 401) - else: - raise e - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/forums/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _, D_ -from sat.core.log import getLogger -from sat.tools.common import data_format - -log = getLogger(__name__) - -name = "forum_view" -label = D_("View") -access = C.PAGES_ACCESS_PUBLIC -template = "forum/view.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node"], 2, service="jid") - - -async def prepare_render(self, request): - data = self.get_r_data(request) - data["show_comments"] = False - blog_page = self.get_page_by_name("blog_view") - request.args[b"before"] = [b""] - request.args[b"reverse"] = [b"1"] - await blog_page.prepare_render(self, request) - request.template_data["login_url"] = self.get_page_redirect_url(request) - - -async def on_data_post(self, request): - profile = self.get_profile(request) - if profile is None: - self.page_error(request, C.HTTP_FORBIDDEN) - type_ = self.get_posted_data(request, "type") - if type_ == "comment": - service, node, body = self.get_posted_data(request, ("service", "node", "body")) - - if not body: - self.page_error(request, C.HTTP_BAD_REQUEST) - mb_data = {"content_rich": body} - try: - await self.host.bridge_call( - "mb_send", service, node, data_format.serialise(mb_data), profile) - except Exception as e: - if "forbidden" in str(e): - self.page_error(request, 401) - else: - raise e - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/g/e/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 - - -redirect = "event_view"
--- a/libervia/pages/g/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from libervia.server import session_iface -from sat.core.log import getLogger - -log = getLogger(__name__) - -access = C.PAGES_ACCESS_PUBLIC -template = "invitation/welcome.html" - - -async def parse_url(self, request): - """check invitation id in URL and start session if needed - - if a session already exists for an other guest/profile, it will be purged - """ - try: - invitation_id = self.next_path(request) - except IndexError: - self.page_error(request) - - web_session, guest_session = self.host.get_session_data( - request, session_iface.IWebSession, session_iface.ISATGuestSession - ) - current_id = guest_session.id - - if current_id is not None and current_id != invitation_id: - log.info( - _( - "killing guest session [{old_id}] because it is connecting with an other ID [{new_id}]" - ).format(old_id=current_id, new_id=invitation_id) - ) - self.host.purge_session(request) - web_session, guest_session = self.host.get_session_data( - request, session_iface.IWebSession, session_iface.ISATGuestSession - ) - current_id = None # FIXME: id not reset here - profile = None - - profile = web_session.profile - if profile is not None and current_id is None: - log.info( - _( - "killing current profile session [{profile}] because a guest id is used" - ).format(profile=profile) - ) - self.host.purge_session(request) - web_session, guest_session = self.host.get_session_data( - request, session_iface.IWebSession, session_iface.ISATGuestSession - ) - profile = None - - if current_id is None: - log.debug(_("checking invitation [{id}]").format(id=invitation_id)) - try: - data = await self.host.bridge_call("invitation_get", invitation_id) - except Exception: - self.page_error(request, C.HTTP_FORBIDDEN) - else: - guest_session.id = invitation_id - guest_session.data = data - else: - data = guest_session.data - - if profile is None: - log.debug(_("connecting profile [{}]").format(profile)) - # we need to connect the profile - profile = data["guest_profile"] - password = data["password"] - try: - await self.host.connect(request, profile, password) - except Exception as e: - log.warning(_("Can't connect profile: {msg}").format(msg=e)) - # FIXME: no good error code correspond - # maybe use a custom one? - self.page_error(request, code=C.HTTP_SERVICE_UNAVAILABLE) - - log.info( - _( - "guest session started, connected with profile [{profile}]".format( - profile=profile - ) - ) - ) - - # we copy data useful in templates - template_data = request.template_data - template_data["norobots"] = True - if "name" in data: - template_data["name"] = data["name"] - if "language" in data: - template_data["locale"] = data["language"] - -def handle_event_interest(self, interest): - if C.bool(interest.get("creator", C.BOOL_FALSE)): - page_name = "event_admin" - else: - page_name = "event_rsvp" - - interest["url"] = self.get_page_by_name(page_name).get_url( - interest.get("service", ""), - interest.get("node", ""), - interest.get("item"), - ) - - if "thumb_url" not in interest and "image" in interest: - interest["thumb_url"] = interest["image"] - -def handle_fis_interest(self, interest): - path = interest.get('path', '') - path_args = [p for p in path.split('/') if p] - subtype = interest.get('subtype') - - if subtype == 'files': - page_name = "files_view" - elif interest.get('subtype') == 'photos': - page_name = "photos_album" - else: - log.warning("unknown interest subtype: {subtype}".format(subtype=subtype)) - return False - - interest["url"] = self.get_page_by_name(page_name).get_url( - interest['service'], *path_args) - -async def prepare_render(self, request): - template_data = request.template_data - profile = self.get_profile(request) - - # interests - template_data['interests_map'] = interests_map = {} - try: - interests = await self.host.bridge_call( - "interests_list", "", "", "", profile) - except Exception: - log.warning(_("Can't get interests list for {profile}").format( - profile=profile)) - else: - # we only want known interests (photos and events for now) - # this dict map namespaces of interest to a callback which can manipulate - # the data. If it returns False, the interest is skipped - ns_data = {} - - for short_name, cb in (('event', handle_event_interest), - ('fis', handle_fis_interest), - ): - try: - namespace = self.host.ns_map[short_name] - except KeyError: - pass - else: - ns_data[namespace] = (cb, short_name) - - for interest in interests: - namespace = interest.get('namespace') - if namespace not in ns_data: - continue - cb, short_name = ns_data[namespace] - if cb(self, interest) == False: - continue - key = interest.get('subtype', short_name) - interests_map.setdefault(key, []).append(interest) - - # main URI - guest_session = self.host.get_session_data(request, session_iface.ISATGuestSession) - main_uri = guest_session.data.get("event_uri") - if main_uri: - include_url = self.get_page_path_from_uri(main_uri) - if include_url is not None: - template_data["include_url"] = include_url
--- a/libervia/pages/lists/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,52 +0,0 @@ -from browser import DOMNode, document, aio -from javascript import JSON -from bridge import AsyncBridge as Bridge, BridgeException -import dialog - -bridge = Bridge() - - -async def on_delete(evt): - evt.stopPropagation() - evt.preventDefault() - target = evt.currentTarget - item_elt = DOMNode(target.closest('.item')) - item_elt.classList.add("selected_for_deletion") - item = JSON.parse(item_elt.dataset.item) - confirmed = await dialog.Confirm( - f"List {item['name']!r} will be deleted, are you sure?", - ok_label="delete", - ).ashow() - - if not confirmed: - item_elt.classList.remove("selected_for_deletion") - return - - try: - await bridge.interest_retract("", item['id']) - except BridgeException as e: - dialog.notification.show( - f"Can't remove list {item['name']!r} from personal interests: {e}", - "error" - ) - else: - print(f"{item['name']!r} removed successfuly from list of interests") - item_elt.classList.add("state_deleted") - item_elt.bind("transitionend", lambda evt: item_elt.remove()) - if item.get("creator", False): - try: - await bridge.ps_node_delete( - item['service'], - item['node'], - ) - except BridgeException as e: - dialog.notification.show( - f"Error while deleting {item['name']!r}: {e}", - "error" - ) - else: - dialog.notification.show(f"{item['name']!r} has been deleted") - - -for elt in document.select('.action_delete'): - elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- a/libervia/pages/lists/create/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from sat.tools.common import data_format -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "list_create" -access = C.PAGES_ACCESS_PROFILE -template = "list/create.html" - - -def parse_url(self, request): - self.get_path_args(request, ["template_id"]) - data = self.get_r_data(request) - if data["template_id"]: - self.http_redirect( - request, - self.get_page_by_name("list_create_from_tpl").get_url(data["template_id"]) - ) - - -async def prepare_render(self, request): - template_data = request.template_data - profile = self.get_profile(request) - tpl_raw = await self.host.bridge_call( - "list_templates_names_get", - "", - profile, - ) - lists_templates = data_format.deserialise(tpl_raw, type_check=list) - template_data["icons_names"] = {tpl['icon'] for tpl in lists_templates} - template_data["lists_templates"] = [ - { - "icon": tpl["icon"], - "name": tpl["name"], - "url": self.get_url(tpl["id"]), - } - for tpl in lists_templates - ]
--- a/libervia/pages/lists/create_from_tpl/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 - -from sat.tools.common import data_format -from sat.core.log import getLogger -from sat.core.i18n import D_ -from sat.core import exceptions -from libervia.server.constants import Const as C -from sat_frontends.bridge.bridge_frontend import BridgeException - -log = getLogger(__name__) - -name = "list_create_from_tpl" -access = C.PAGES_ACCESS_PROFILE -template = "list/create_from_template.html" - - -def parse_url(self, request): - self.get_path_args(request, ["template_id"]) - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_id = data["template_id"] - if not template_id: - self.page_error(request, C.HTTP_BAD_REQUEST) - - template_data = request.template_data - profile = self.get_profile(request) - tpl_raw = await self.host.bridge_call( - "list_template_get", - template_id, - "", - profile, - ) - template = data_format.deserialise(tpl_raw) - template['id'] = template_id - template_data["list_template"] = template - -async def on_data_post(self, request): - data = self.get_r_data(request) - template_id = data['template_id'] - name, access = self.get_posted_data(request, ('name', 'access')) - if access == 'private': - access_model = 'whitelist' - elif access == 'public': - access_model = 'open' - else: - log.warning(f"Unknown access for template creation: {access}") - self.page_error(request, C.HTTP_BAD_REQUEST) - profile = self.get_profile(request) - try: - service, node = await self.host.bridge_call( - "list_template_create", template_id, name, access_model, profile - ) - except BridgeException as e: - if e.condition == "conflict": - raise exceptions.DataError(D_("A list with this name already exists")) - else: - log.error(f"Can't create list from template: {e}") - raise e - data["post_redirect_page"] = ( - self.get_page_by_name("lists"), - service, - node or "@", - )
--- a/libervia/pages/lists/edit/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from twisted.internet import defer -from sat.tools.common import template_xmlui -from sat.tools.common import data_format -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "list_edit" -access = C.PAGES_ACCESS_PROFILE -template = "list/edit.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@") - data = self.get_r_data(request) - if data["item_id"] is None: - log.warning(_("no list item id specified")) - self.page_error(request, C.HTTP_BAD_REQUEST) - -@defer.inlineCallbacks -def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node, item_id = ( - data.get("service", ""), - data.get("node", ""), - data["item_id"], - ) - profile = self.get_profile(request) - - # we don't ignore "author" below to keep it when a list item is edited - # by node owner/admin and "consistent publisher" is activated - ignore = ( - "publisher", - "author", - "author_jid", - "author_email", - "created", - "updated", - "comments_uri", - ) - list_raw = yield self.host.bridge_call( - "list_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [item_id], - "", - data_format.serialise({}), - profile, - ) - list_items, metadata = data_format.deserialise(list_raw, type_check=list) - list_item = [template_xmlui.create(self.host, x, ignore=ignore) for x in list_items][0] - - try: - # small trick to get a one line text input instead of the big textarea - list_item.widgets["labels"].type = "string" - list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace( - "\n", ", " - ) - except KeyError: - pass - - # for now we don't have XHTML editor, so we'll go with a TextBox and a convertion - # to a text friendly syntax using markdown - wid = list_item.widgets['body'] - if wid.type == "xhtmlbox": - wid.type = "textbox" - wid.value = yield self.host.bridge_call( - "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", - False, profile) - - template_data["new_list_item_xmlui"] = list_item - - -async def on_data_post(self, request): - data = self.get_r_data(request) - service = data["service"] - node = data["node"] - item_id = data["item_id"] - posted_data = self.get_all_posted_data(request) - if not posted_data["title"] or not posted_data["body"]: - self.page_error(request, C.HTTP_BAD_REQUEST) - try: - posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] - except (KeyError, IndexError): - pass - profile = self.get_profile(request) - - # we convert back body to XHTML - body = await self.host.bridge_call( - "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, - False, profile) - posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, - body=body)] - - extra = {'update': True} - await self.host.bridge_call( - "list_set", service.full(), node, posted_data, "", item_id, - data_format.serialise(extra), profile - ) - data["post_redirect_page"] = ( - self.get_page_by_name("list_view"), - service.full(), - node or "@", - item_id - ) - return C.POST_NO_CONFIRM
--- a/libervia/pages/lists/new/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from sat.tools.common import template_xmlui -from sat.core.log import getLogger - -log = getLogger(__name__) - - -name = "list_new" -access = C.PAGES_ACCESS_PROFILE -template = "list/create_item.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node"], service="jid", node="@") - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node = data.get("service", ""), data.get("node", "") - profile = self.get_profile(request) - schema = await self.host.bridge_call("list_schema_get", service.full(), node, profile) - data["schema"] = schema - # following fields are handled in backend - ignore = ( - "author", - "author_jid", - "author_email", - "created", - "updated", - "comments_uri", - "status", - "milestone", - "priority", - ) - xmlui_obj = template_xmlui.create(self.host, schema, ignore=ignore) - try: - # small trick to get a one line text input instead of the big textarea - xmlui_obj.widgets["labels"].type = "string" - except KeyError: - pass - - try: - wid = xmlui_obj.widgets['body'] - except KeyError: - pass - else: - if wid.type == "xhtmlbox": - # same as for list_edit, we have to convert for now - wid.type = "textbox" - wid.value = await self.host.bridge_call( - "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", - False, profile) - template_data["new_list_item_xmlui"] = xmlui_obj - - -async def on_data_post(self, request): - data = self.get_r_data(request) - service = data["service"] - node = data["node"] - posted_data = self.get_all_posted_data(request) - if (("title" in posted_data and not posted_data["title"]) - or ("body" in posted_data and not posted_data["body"])): - self.page_error(request, C.HTTP_BAD_REQUEST) - try: - posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] - except (KeyError, IndexError): - pass - profile = self.get_profile(request) - - # we convert back body to XHTML - if "body" in posted_data: - body = await self.host.bridge_call( - "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, - False, profile) - posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, - body=body)] - - - await self.host.bridge_call( - "list_set", service.full(), node, posted_data, "", "", "", profile - ) - # we don't want to redirect to creation page on success, but to list overview - data["post_redirect_page"] = ( - self.get_page_by_name("lists"), - service.full(), - node or "@", - )
--- a/libervia/pages/lists/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from twisted.words.protocols.jabber import jid -from sat.core.i18n import _, D_ -from sat.core.log import getLogger -from sat.tools.common import data_format - -log = getLogger(__name__) - -name = "lists_disco" -label = D_("Lists Discovery") -access = C.PAGES_ACCESS_PUBLIC -template = "list/discover.html" - -async def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - template_data["url_list_create"] = self.get_page_by_name("list_create").url - lists_directory_config = self.host.options["lists_directory_json"] - lists_directory = request.template_data["lists_directory"] = [] - - if lists_directory_config: - try: - for list_data in lists_directory_config: - service = list_data["service"] - node = list_data["node"] - name = list_data["name"] - url = self.get_page_by_name("lists").get_url(service, node) - lists_directory.append({"name": name, "url": url, "from_config": True}) - except KeyError as e: - log.warning("Missing field in lists_directory_json: {msg}".format(msg=e)) - except Exception as e: - log.warning("Can't decode lists directory: {msg}".format(msg=e)) - - if profile is not None: - try: - lists_list_raw = await self.host.bridge_call("lists_list", "", "", profile) - except Exception as e: - log.warning( - _("Can't get list of registered lists for {profile}: {reason}") - .format(profile=profile, reason=e) - ) - else: - lists_list = data_format.deserialise(lists_list_raw, type_check=list) - for list_data in lists_list: - service = list_data["service"] - node = list_data["node"] - list_data["url"] = self.get_page_by_name("lists").get_url(service, node) - list_data["from_config"] = False - lists_directory.append(list_data) - - icons_names = set() - for list_data in lists_directory: - try: - icons_names.add(list_data['icon_name']) - except KeyError: - pass - if icons_names: - template_data["icons_names"] = icons_names - - -def on_data_post(self, request): - jid_str = self.get_posted_data(request, "jid") - try: - jid_ = jid.JID(jid_str) - except RuntimeError: - self.page_error(request, C.HTTP_BAD_REQUEST) - # for now we just use default node - url = self.get_page_by_name("lists").get_url(jid_.full(), "@") - self.http_redirect(request, url)
--- a/libervia/pages/lists/view/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,133 +0,0 @@ -from browser import window, document, aio, bind -from invitation import InvitationManager -from javascript import JSON -from bridge import Async as Bridge, BridgeException -import dialog - - -bridge = Bridge() -lists_ns = window.lists_ns -pubsub_service = window.pubsub_service -pubsub_node = window.pubsub_node -list_type = window.list_type -try: - affiliations = window.affiliations.to_dict() -except AttributeError: - pass - -@bind("#button_manage", "click") -def manage_click(evt): - evt.stopPropagation() - evt.preventDefault() - pubsub_data = { - "namespace": lists_ns, - "service": pubsub_service, - "node": pubsub_node - } - try: - name = pubsub_node.split('_', 1)[1] - except IndexError: - pass - else: - name = name.strip() - if name: - pubsub_data['name'] = name - manager = InvitationManager("pubsub", pubsub_data) - manager.attach(affiliations=affiliations) - - -async def on_delete(evt): - item_elt = evt.target.closest(".item") - if item_elt is None: - dialog.notification.show( - "Can't find parent item element", - level="error" - ) - return - item_elt.classList.add("selected_for_deletion") - item = JSON.parse(item_elt.dataset.item) - confirmed = await dialog.Confirm( - f"{item['name']!r} will be deleted, are you sure?", - ok_label="delete", - ok_color="danger", - ).ashow() - item_elt.classList.remove("selected_for_deletion") - if confirmed: - try: - await bridge.ps_item_retract(pubsub_service, pubsub_node, item["id"], True) - except Exception as e: - dialog.notification.show( - f"Can't delete list item: {e}", - level="error" - ) - else: - dialog.notification.show("list item deleted successfuly") - item_elt.remove() - - -async def on_next_state(evt): - """Update item with next state - - Only used with grocery list at the moment - """ - evt.stopPropagation() - evt.preventDefault() - # FIXME: states are currently hardcoded, it would be better to use schema - item_elt = evt.target.closest(".item") - if item_elt is None: - dialog.notification.show( - "Can't find parent item element", - level="error" - ) - return - item = JSON.parse(item_elt.dataset.item) - try: - status = item["status"] - except (KeyError, IndexError) as e: - dialog.notification.show( - f"Can't get item status: {e}", - level="error" - ) - status = "to_buy" - if status == "to_buy": - item["status"] = "bought" - class_update_method = item_elt.classList.add - checked = True - elif status == "bought": - item["status"] = "to_buy" - checked = False - class_update_method = item_elt.classList.remove - else: - dialog.notification.show( - f"unexpected item status: {status!r}", - level="error" - ) - return - item_elt.dataset.item = JSON.stringify(item) - try: - await bridge.list_set( - pubsub_service, - pubsub_node, - # FIXME: value type should be consistent, or we should serialise - {k: (v if isinstance(v, list) else [v]) for k,v in item.items()}, - "", - item["id"], - "" - ) - except BridgeException as e: - dialog.notification.show( - f"Can't udate list item: {e.message}", - level="error" - ) - else: - evt.target.checked = checked - class_update_method("list-item-closed") - - -if list_type == "grocery": - for elt in document.select('.click_to_delete'): - elt.bind("click", lambda evt: aio.run(on_delete(evt))) - - for elt in document.select('.click_to_next_state'): - elt.bind("click", lambda evt: aio.run(on_next_state(evt))) -
--- a/libervia/pages/lists/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 - -from sat.tools.common import template_xmlui -from sat.tools.common import data_objects -from sat.tools.common import data_format -from sat.core.log import getLogger -from sat_frontends.bridge.bridge_frontend import BridgeException -from libervia.server.constants import Const as C - -log = getLogger(__name__) - -name = "lists" -access = C.PAGES_ACCESS_PUBLIC -template = "list/overview.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node"], service="jid") - data = self.get_r_data(request) - service, node = data["service"], data["node"] - if node is None: - self.http_redirect(request, self.get_page_by_name("lists_disco").url) - if node == "@": - node = data["node"] = "" - template_data = request.template_data - template_data["url_list_items"] = self.get_url(service.full(), node or "@") - template_data["url_list_new"] = self.get_page_by_name("list_new").get_url( - service.full(), node or "@") - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node = data["service"], data["node"] - submitted_node = await self.host.bridge_call( - "ps_schema_submitted_node_get", - node or self.host.ns_map["tickets"] - ) - profile = self.get_profile(request) or C.SERVICE_PROFILE - - self.check_cache(request, C.CACHE_PUBSUB, service=service, node=node, short="tickets") - - try: - lists_types = self.get_page_data(request, "lists_types") - if lists_types is None: - lists_types = {} - self.set_page_data(request, "lists_types", lists_types) - list_type = lists_types[(service, node)] - except KeyError: - ns_tickets_type = self.host.ns_map["tickets_type"] - schema_raw = await self.host.bridge_call( - "ps_schema_dict_get", - service.full(), - submitted_node, - profile - ) - schema = data_format.deserialise(schema_raw) - try: - list_type_field = next( - f for f in schema["fields"] if f["type"] == "hidden" - and f.get("name") == ns_tickets_type - ) - except StopIteration: - list_type = lists_types[(service, node)] = None - else: - if list_type_field.get("value") is None: - list_type = None - else: - list_type = list_type_field["value"].lower().strip() - lists_types[(service, node)] = list_type - - data["list_type"] = template_data["list_type"] = list_type - - extra = self.get_pubsub_extra(request) - extra["labels_as_list"] = C.BOOL_TRUE - self.handle_search(request, extra) - - list_raw = await self.host.bridge_call( - "list_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [], - "", - data_format.serialise(extra), - profile, - ) - if profile != C.SERVICE_PROFILE: - try: - affiliations = await self.host.bridge_call( - "ps_node_affiliations_get", - service.full() if service else "", - submitted_node, - profile - ) - except BridgeException as e: - log.warning( - f"Can't get affiliations for node {submitted_node!r} at {service}: {e}" - ) - template_data["owner"] = False - else: - is_owner = affiliations.get(self.get_jid(request).userhost()) == 'owner' - template_data["owner"] = is_owner - if is_owner: - self.expose_to_scripts( - request, - affiliations={str(e): str(a) for e, a in affiliations.items()} - ) - else: - template_data["owner"] = False - - list_items, metadata = data_format.deserialise(list_raw, type_check=list) - template_data["list_items"] = [ - template_xmlui.create(self.host, x) for x in list_items - ] - view_url = self.get_page_by_name('list_view').get_url(service.full(), node or '@') - template_data["on_list_item_click"] = data_objects.OnClick( - url=f"{view_url}/{{item.id}}" - ) - self.set_pagination(request, metadata) - self.expose_to_scripts( - request, - lists_ns=self.host.ns_map["tickets"], - pubsub_service=service.full(), - pubsub_node=node, - list_type=list_type, - ) - - -async def on_data_post(self, request): - data = self.get_r_data(request) - profile = self.get_profile(request) - service = data["service"] - node = data["node"] - list_type = self.get_posted_data(request, ("type",)) - if list_type == "grocery": - name, quantity = self.get_posted_data(request, ("name", "quantity")) - if not name: - self.page_error(request, C.HTTP_BAD_REQUEST) - item_data = { - "name": [name], - } - if quantity: - item_data["quantity"] = [quantity] - await self.host.bridge_call( - "list_set", service.full(), node, item_data, "", "", "", profile - ) - return C.POST_NO_CONFIRM - else: - raise NotImplementedError( - f"Can't use quick list item set for list of type {list_type!r}" - )
--- a/libervia/pages/lists/view_item/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,57 +0,0 @@ -from browser import document, window, aio -from bridge import AsyncBridge as Bridge -import dialog - -try: - pubsub_service = window.pubsub_service - pubsub_node = window.pubsub_node - pubsub_item = window.pubsub_item -except AttributeError: - can_delete = False -else: - bridge = Bridge() - can_delete = True - - -async def on_delete(evt): - evt.stopPropagation() - confirmed = await dialog.Confirm( - "This item will be deleted, are you sure?", - ok_label="delete", - ok_color="danger", - ).ashow() - if confirmed: - try: - comments_service = window.comments_service - comments_node = window.comments_node - except AttributeError: - pass - else: - print(f"deleting comment node at [{comments_service}] {comments_node!r}") - try: - await bridge.ps_node_delete(comments_service, comments_node) - except Exception as e: - dialog.notification.show( - f"Can't delete comment node: {e}", - level="error" - ) - - print(f"deleting list item {pubsub_item!r} at [{pubsub_service}] {pubsub_node!r}") - try: - await bridge.ps_item_retract(pubsub_service, pubsub_node, pubsub_item, True) - except Exception as e: - dialog.notification.show( - f"Can't delete list item: {e}", - level="error" - ) - else: - # FIXME: Q&D way to get list view URL, need to have a proper method (would - # be nice to have a way to reference pages by name from browser) - list_url = '/'.join(window.location.pathname.split('/')[:-1]).replace( - 'view_item', 'view') - window.location.replace(list_url) - - -if can_delete: - for elt in document.select('.action_delete'): - elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- a/libervia/pages/lists/view_item/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 - -from twisted.words.protocols.jabber import jid -from sat.core.i18n import _, D_ -from sat.tools.common import template_xmlui -from sat.tools.common import uri -from sat.tools.common import data_format -from sat.core.log import getLogger -from sat_frontends.bridge.bridge_frontend import BridgeException -from libervia.server.constants import Const as C -from libervia.server.utils import SubPage -from libervia.server import session_iface - -log = getLogger(__name__) - - -name = "list_view" -access = C.PAGES_ACCESS_PUBLIC -template = "list/item.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@") - data = self.get_r_data(request) - if data["item_id"] is None: - log.warning(_("no list item id specified")) - self.page_error(request, C.HTTP_BAD_REQUEST) - - -def add_breadcrumb(self, request, breadcrumbs): - data = self.get_r_data(request) - list_url = self.get_page_by_name("lists").get_url(data["service"].full(), - data.get("node") or "@") - breadcrumbs.append({ - "label": D_("List"), - "url": list_url - }) - breadcrumbs.append({ - "label": D_("Item"), - }) - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - session = self.host.get_session_data(request, session_iface.IWebSession) - service, node, item_id = ( - data.get("service", ""), - data.get("node", ""), - data["item_id"], - ) - namespace = node or self.host.ns_map["tickets"] - node = await self.host.bridge_call("ps_schema_submitted_node_get", namespace) - - profile = self.get_profile(request) - if profile is None: - profile = C.SERVICE_PROFILE - - list_raw = await self.host.bridge_call( - "list_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [item_id], - "", - data_format.serialise({"labels_as_list": C.BOOL_TRUE}), - profile, - ) - list_items, __ = data_format.deserialise(list_raw, type_check=list) - list_item = [template_xmlui.create(self.host, x) for x in list_items][0] - template_data["item"] = list_item - try: - comments_uri = list_item.widgets["comments_uri"].value - except KeyError: - pass - else: - if comments_uri: - uri_data = uri.parse_xmpp_uri(comments_uri) - template_data["comments_node"] = comments_node = uri_data["node"] - template_data["comments_service"] = comments_service = uri_data["path"] - try: - comments = data_format.deserialise(await self.host.bridge_call( - "mb_get", comments_service, comments_node, C.NO_LIMIT, [], - data_format.serialise({}), profile - )) - except BridgeException as e: - if e.classname == 'NotFound' or e.condition == 'item-not-found': - log.warning( - _("Can't find comment node at [{service}] {node!r}") - .format(service=comments_service, node=comments_node) - ) - else: - raise e - else: - template_data["comments"] = comments - template_data["login_url"] = self.get_page_redirect_url(request) - self.expose_to_scripts( - request, - comments_node=comments_node, - comments_service=comments_service, - ) - - if session.connected: - # we activate modification action (edit, delete) only if user is the publisher or - # the node owner - publisher = jid.JID(list_item.widgets["publisher"].value) - is_publisher = publisher.userhostJID() == session.jid.userhostJID() - affiliation = None - if not is_publisher: - affiliation = await self.host.get_affiliation(request, service, node) - if is_publisher or affiliation == "owner": - self.expose_to_scripts( - request, - pubsub_service = service.full(), - pubsub_node = node, - pubsub_item = item_id, - ) - template_data["can_modify"] = True - template_data["url_list_item_edit"] = self.get_url_by_path( - SubPage("list_edit"), - service.full(), - node or "@", - item_id, - ) - - # we add xmpp: URI - uri_args = {'path': service.full()} - uri_args['node'] = node - if item_id: - uri_args['item'] = item_id - template_data['xmpp_uri'] = uri.build_xmpp_uri('pubsub', **uri_args) - - -async def on_data_post(self, request): - type_ = self.get_posted_data(request, "type") - if type_ == "comment": - blog_page = self.get_page_by_name("blog_view") - await blog_page.on_data_post(self, request) - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/login/logged/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,18 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server import session_iface -from sat.core.log import getLogger - -log = getLogger(__name__) - -"""SàT log-in page, with link to create an account""" - -template = "login/logged.html" - - -def prepare_render(self, request): - template_data = request.template_data - session_data = self.host.get_session_data(request, session_iface.IWebSession) - template_data["guest_session"] = session_data.guest - template_data["session_started"] = session_data.started
--- a/libervia/pages/login/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import _ -from sat.core import exceptions -from libervia.server.constants import Const as C -from libervia.server import session_iface -from twisted.internet import defer -from sat.core.log import getLogger - -log = getLogger(__name__) - -"""SàT log-in page, with link to create an account""" - -name = "login" -access = C.PAGES_ACCESS_PUBLIC -template = "login/login.html" - - -def prepare_render(self, request): - template_data = request.template_data - - # we redirect to logged page if a session is active - profile = self.get_profile(request) - if profile is not None: - self.page_redirect("/login/logged", request) - - # login error message - session_data = self.host.get_session_data(request, session_iface.IWebSession) - login_error = session_data.pop_page_data(self, "login_error") - if login_error is not None: - template_data["S_C"] = C # we need server constants in template - template_data["login_error"] = login_error - template_data["empty_password_allowed"] = bool( - self.host.options["empty_password_allowed_warning_dangerous_list"] - ) - - # register page url - if self.host.options["allow_registration"]: - template_data["register_url"] = self.get_page_redirect_url(request, "register") - - # if login is set, we put it in template to prefill field - template_data["login"] = session_data.pop_page_data(self, "login") - - -def login_error(self, request, error_const): - """set login_error in page data - - @param error_const(unicode): one of login error constant - @return C.POST_NO_CONFIRM: avoid confirm message - """ - session_data = self.host.get_session_data(request, session_iface.IWebSession) - session_data.set_page_data(self, "login_error", error_const) - return C.POST_NO_CONFIRM - - -async def on_data_post(self, request): - profile = self.get_profile(request) - type_ = self.get_posted_data(request, "type") - if type_ == "disconnect": - if profile is None: - log.warning(_("Disconnect called when no profile is logged")) - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - self.host.purge_session(request) - return C.POST_NO_CONFIRM - elif type_ == "login": - login, password = self.get_posted_data(request, ("login", "password")) - try: - status = await self.host.connect(request, login, password) - except exceptions.ProfileUnknownError: - # the profile doesn't exist, we return the same error as for invalid password - # to avoid bruteforcing valid profiles - log.warning(f"login tentative with invalid profile: {login!r}") - return login_error(self, request, C.PROFILE_AUTH_ERROR) - except ValueError as e: - message = str(e) - if message in (C.XMPP_AUTH_ERROR, C.PROFILE_AUTH_ERROR): - return login_error(self, request, message) - else: - # this error was not expected! - raise e - except exceptions.TimeOutError: - return login_error(self, request, C.NO_REPLY) - else: - if status in (C.PROFILE_LOGGED, C.PROFILE_LOGGED_EXT_JID, C.SESSION_ACTIVE): - # Profile has been logged correctly - self.redirect_or_continue(request) - else: - log.error(_("Unhandled status: {status}".format(status=status))) - else: - self.page_error(request, C.HTTP_BAD_REQUEST)
--- a/libervia/pages/merge-requests/disco/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.words.protocols.jabber import jid -from sat.core.log import getLogger - -log = getLogger(__name__) - - -name = "merge-requests_disco" -access = C.PAGES_ACCESS_PUBLIC -template = "merge-request/discover.html" - - -def prepare_render(self, request): - mr_handlers_config = self.host.options["mr_handlers_json"] - if mr_handlers_config: - handlers = request.template_data["mr_handlers"] = [] - try: - for handler_data in mr_handlers_config: - service = handler_data["service"] - node = handler_data["node"] - name = handler_data["name"] - url = self.get_page_by_name("merge-requests").get_url(service, node) - handlers.append({"name": name, "url": url}) - except KeyError as e: - log.warning("Missing field in mr_handlers_json: {msg}".format(msg=e)) - except Exception as e: - log.warning("Can't decode mr handlers: {msg}".format(msg=e)) - - -def on_data_post(self, request): - jid_str = self.get_posted_data(request, "jid") - try: - jid_ = jid.JID(jid_str) - except RuntimeError: - self.page_error(request, C.HTTP_BAD_REQUEST) - # for now we just use default node - url = self.get_page_by_name("merge-requests").get_url(jid_.full(), "@") - self.http_redirect(request, url)
--- a/libervia/pages/merge-requests/edit/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from sat.tools.common import template_xmlui -from sat.tools.common import data_format -from sat.core.log import getLogger - -"""merge-requests edition""" - -name = "merge-requests_edit" -access = C.PAGES_ACCESS_PROFILE -template = "merge-request/edit.html" -log = getLogger(__name__) - - -def parse_url(self, request): - try: - item_id = self.next_path(request) - except IndexError: - log.warning(_("no list item id specified")) - self.page_error(request, C.HTTP_BAD_REQUEST) - - data = self.get_r_data(request) - data["list_item_id"] = item_id - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node, list_item_id = ( - data.get("service", ""), - data.get("node", ""), - data["list_item_id"], - ) - profile = self.get_profile(request) - - ignore = ( - "publisher", - "author", - "author_jid", - "author_email", - "created", - "updated", - "comments_uri", - "request_data", - "type", - ) - merge_requests = data_format.deserialise( - await self.host.bridge_call( - "merge_requests_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [list_item_id], - "", - data_format.serialise({}), - profile, - ) - ) - list_item = template_xmlui.create( - self.host, merge_requests['items'][0], ignore=ignore - ) - - try: - # small trick to get a one line text input instead of the big textarea - list_item.widgets["labels"].type = "string" - list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace( - "\n", ", " - ) - except KeyError: - pass - - # same as list_edit - wid = list_item.widgets['body'] - if wid.type == "xhtmlbox": - wid.type = "textbox" - wid.value = await self.host.bridge_call( - "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", - False, profile) - - template_data["new_list_item_xmlui"] = list_item - - -async def on_data_post(self, request): - data = self.get_r_data(request) - service = data["service"] - node = data["node"] - list_item_id = data["list_item_id"] - posted_data = self.get_all_posted_data(request) - if not posted_data["title"] or not posted_data["body"]: - self.page_error(request, C.HTTP_BAD_REQUEST) - try: - posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] - except (KeyError, IndexError): - pass - profile = self.get_profile(request) - - # we convert back body to XHTML - body = await self.host.bridge_call( - "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, - False, profile) - posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, - body=body)] - - extra = {'update': True} - await self.host.bridge_call( - "merge_request_set", - service.full(), - node, - "", - "auto", - posted_data, - "", - list_item_id, - data_format.serialise(extra), - profile, - ) - # we don't want to redirect to edit page on success, but to list overview - data["post_redirect_page"] = ( - self.get_page_by_name("merge-requests"), - service.full(), - node or "@", - )
--- a/libervia/pages/merge-requests/new/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.log import getLogger - -log = getLogger(__name__) - - -name = "merge-requests_new" -access = C.PAGES_ACCESS_PUBLIC -template = "merge-request/create.html"
--- a/libervia/pages/merge-requests/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.tools.common import template_xmlui -from sat.tools.common import data_format -from sat.tools.common import data_objects -from sat.core.log import getLogger - -log = getLogger(__name__) - - -name = "merge-requests" -access = C.PAGES_ACCESS_PUBLIC -template = "list/overview.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "node"], service="jid") - data = self.get_r_data(request) - service, node = data["service"], data["node"] - if node is None: - self.page_redirect("merge-requests_disco", request) - if node == "@": - node = data["node"] = "" - self.check_cache( - request, C.CACHE_PUBSUB, service=service, node=node, short="merge-requests" - ) - template_data = request.template_data - template_data["url_list_items"] = self.get_page_by_name("merge-requests").get_url( - service.full(), node - ) - template_data["url_list_new"] = self.get_sub_page_url(request, "merge-requests_new") - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - service, node = data["service"], data["node"] - profile = self.get_profile(request) or C.SERVICE_PROFILE - - merge_requests = data_format.deserialise( - await self.host.bridge_call( - "merge_requests_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [], - "", - data_format.serialise({"labels_as_list": C.BOOL_TRUE}), - profile, - ) - ) - - template_data["list_items"] = [ - template_xmlui.create(self.host, x) for x in merge_requests['items'] - ] - template_data["on_list_item_click"] = data_objects.OnClick( - url=self.get_sub_page_url(request, "merge-requests_view") + "/{item.id}" - )
--- a/libervia/pages/merge-requests/view/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from sat.core.i18n import _ -from libervia.server.utils import SubPage -from libervia.server import session_iface -from twisted.words.protocols.jabber import jid -from sat.tools.common import template_xmlui -from sat.tools.common import uri -from sat.tools.common import data_format -from sat.core.log import getLogger - -name = "merge-requests_view" -access = C.PAGES_ACCESS_PUBLIC -template = "merge-request/item.html" -log = getLogger(__name__) - - -def parse_url(self, request): - try: - item_id = self.next_path(request) - except IndexError: - log.warning(_("no list item id specified")) - self.page_error(request, C.HTTP_BAD_REQUEST) - - data = self.get_r_data(request) - data["list_item_id"] = item_id - - -async def prepare_render(self, request): - data = self.get_r_data(request) - template_data = request.template_data - session = self.host.get_session_data(request, session_iface.IWebSession) - service, node, list_item_id = ( - data.get("service", ""), - data.get("node", ""), - data["list_item_id"], - ) - profile = self.get_profile(request) - - if profile is None: - profile = C.SERVICE_PROFILE - - merge_requests = data_format.deserialise( - await self.host.bridge_call( - "merge_requests_get", - service.full() if service else "", - node, - C.NO_LIMIT, - [list_item_id], - "", - data_format.serialise({"parse": C.BOOL_TRUE, "labels_as_list": C.BOOL_TRUE}), - profile, - ) - ) - list_item = template_xmlui.create( - self.host, merge_requests['items'][0], ignore=["request_data", "type"] - ) - template_data["item"] = list_item - template_data["patches"] = merge_requests['items_patches'][0] - comments_uri = list_item.widgets["comments_uri"].value - if comments_uri: - uri_data = uri.parse_xmpp_uri(comments_uri) - template_data["comments_node"] = comments_node = uri_data["node"] - template_data["comments_service"] = comments_service = uri_data["path"] - template_data["comments"] = data_format.deserialise(await self.host.bridge_call( - "mb_get", comments_service, comments_node, C.NO_LIMIT, [], - data_format.serialise({}), profile - )) - - template_data["login_url"] = self.get_page_redirect_url(request) - - if session.connected: - # we set edition URL only if user is the publisher or the node owner - publisher = jid.JID(list_item.widgets["publisher"].value) - is_publisher = publisher.userhostJID() == session.jid.userhostJID() - affiliation = None - if not is_publisher: - node = node or self.host.ns_map["merge_requests"] - affiliation = await self.host.get_affiliation(request, service, node) - if is_publisher or affiliation == "owner": - template_data["url_list_item_edit"] = self.get_url_by_path( - SubPage("merge-requests"), - service.full(), - node or "@", - SubPage("merge-requests_edit"), - list_item_id, - ) - - -async def on_data_post(self, request): - type_ = self.get_posted_data(request, "type") - if type_ == "comment": - blog_page = self.get_page_by_name("blog_view") - await blog_page.on_data_post(self, request) - else: - log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/photos/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,62 +0,0 @@ -from browser import window, bind, DOMNode -from javascript import JSON -from bridge import Bridge -import dialog - -bridge = Bridge() - - -def album_delete_cb(item_elt, item): - print(f"deleted {item['name']}") - - -def album_delete_eb(failure, item_elt, item): - # TODO: cleaner error notification - window.alert(f"error while deleting {item['name']}: failure") - - -def interest_retract_cb(item_elt, item): - print(f"{item['name']} removed successfuly from list of interests") - item_elt.classList.add("state_deleted") - item_elt.bind("transitionend", lambda evt: item_elt.remove()) - bridge.file_sharing_delete( - item['service'], - item.get('path', ''), - item.get('files_namespace', ''), - callback=lambda __: album_delete_cb(item_elt, item), - errback=lambda failure: album_delete_eb(failure, item_elt, item), - ) - - -def interest_retract_eb(failure_, item_elt, item): - # TODO: cleaner error notification - window.alert(f"Can't delete album {item['name']}: {failure_['message']}") - - -def delete_ok(evt, notif_elt, item_elt, item): - bridge.interest_retract( - "", item['id'], - callback=lambda: interest_retract_cb(item_elt, item), - errback=lambda failure:interest_retract_eb(failure, item_elt, item)) - - -def delete_cancel(evt, notif_elt, item_elt, item): - notif_elt.remove() - item_elt.classList.remove("selected_for_deletion") - - -@bind(".action_delete", "click") -def on_delete(evt): - evt.stopPropagation() - target = evt.currentTarget - item_elt = DOMNode(target.closest('.item')) - item_elt.classList.add("selected_for_deletion") - item = JSON.parse(item_elt.dataset.item) - dialog.Confirm( - f"album {item['name']!r} will be deleted (inluding all its photos), " - f"are you sure?", - ok_label="delete", - ).show( - ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item), - cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item), - )
--- a/libervia/pages/photos/album/_browser/__init__.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,305 +0,0 @@ -from browser import document, window, bind, html, DOMNode, aio -from javascript import JSON -from bridge import Bridge, AsyncBridge -from template import Template -import dialog -from slideshow import SlideShow -from invitation import InvitationManager -import alt_media_player -# we use tmp_aio because `blob` is not handled in Brython's aio -import tmp_aio -import loading - - -cache_path = window.cache_path -files_service = window.files_service -files_path = window.files_path -try: - affiliations = window.affiliations.to_dict() -except AttributeError: - pass -bridge = Bridge() -async_bridge = AsyncBridge() - -alt_media_player.install_if_needed() - -photo_tpl = Template('photo/item.html') -player_tpl = Template('components/media_player.html') - -# file upload - -def on_progress(ev, photo_elt): - if ev.lengthComputable: - percent = int(ev.loaded/ev.total*100) - update_progress(photo_elt, percent) - - -def on_load(file_, photo_elt): - update_progress(photo_elt, 100) - photo_elt.classList.add("progress_finished") - photo_elt.classList.remove("progress_started") - photo_elt.select_one('.action_delete').bind("click", on_delete) - print(f"file {file_.name} uploaded correctly") - - -def on_error(failure, file_, photo_elt): - dialog.notification.show( - f"can't upload {file_.name}: {failure}", - level="error" - ) - - -def update_progress(photo_elt, new_value): - progress_elt = photo_elt.select_one("progress") - progress_elt.value = new_value - progress_elt.text = f"{new_value}%" - - -def on_slot_cb(file_, upload_slot, photo_elt): - put_url, get_url, headers = upload_slot - xhr = window.XMLHttpRequest.new() - xhr.open("PUT", put_url, True) - xhr.upload.bind('progress', lambda ev: on_progress(ev, photo_elt)) - xhr.upload.bind('load', lambda ev: on_load(file_, photo_elt)) - xhr.upload.bind('error', lambda ev: on_error(xhr.response, file_, photo_elt)) - xhr.setRequestHeader('Xmpp-File-Path', window.encodeURIComponent(files_path)) - xhr.setRequestHeader('Xmpp-File-No-Http', "true") - xhr.send(file_) - - -def on_slot_eb(file_, failure, photo_elt): - dialog.notification.show( - f"Can't get upload slot: {failure['message']}", - level="error" - ) - photo_elt.remove() - - -def upload_files(files): - print(f"uploading {len(files)} files") - album_items = document['album_items'] - for file_ in files: - url = window.URL.createObjectURL(file_) - photo_elt = photo_tpl.get_elt({ - "file": { - "name": file_.name, - # we don't want to open the file on click, it's not yet the - # uploaded URL - "url": url, - # we have no thumb yet, so we use the whole image - # TODO: reduce image for preview - "thumb_url": url, - }, - "uploading": True, - }) - photo_elt.classList.add("progress_started") - album_items <= photo_elt - - bridge.file_http_upload_get_slot( - file_.name, - file_.size, - file_.type or '', - files_service, - callback=lambda upload_slot, file_=file_, photo_elt=photo_elt: - on_slot_cb(file_, upload_slot, photo_elt), - errback=lambda failure, file_=file_, photo_elt=photo_elt: - on_slot_eb(file_, failure, photo_elt), - ) - - -@bind("#file_drop", "drop") -def on_file_select(evt): - evt.stopPropagation() - evt.preventDefault() - files = evt.dataTransfer.files - upload_files(files) - - -@bind("#file_drop", "dragover") -def on_drag_over(evt): - evt.stopPropagation() - evt.preventDefault() - evt.dataTransfer.dropEffect = 'copy' - - -@bind("#file_input", "change") -def on_file_input_change(evt): - files = evt.currentTarget.files - upload_files(files) - -# delete - -def file_delete_cb(item_elt, item): - item_elt.classList.add("state_deleted") - item_elt.bind("transitionend", lambda evt: item_elt.remove()) - print(f"deleted {item['name']}") - - -def file_delete_eb(failure, item_elt, item): - dialog.notification.show( - f"error while deleting {item['name']}: failure", - level="error" - ) - - -def delete_ok(evt, notif_elt, item_elt, item): - file_path = f"{files_path.rstrip('/')}/{item['name']}" - bridge.file_sharing_delete( - files_service, - file_path, - "", - callback=lambda : file_delete_cb(item_elt, item), - errback=lambda failure: file_delete_eb(failure, item_elt, item), - ) - - -def delete_cancel(evt, notif_elt, item_elt, item): - notif_elt.remove() - item_elt.classList.remove("selected_for_deletion") - - -def on_delete(evt): - evt.stopPropagation() - target = evt.currentTarget - item_elt = DOMNode(target.closest('.item')) - item_elt.classList.add("selected_for_deletion") - item = JSON.parse(item_elt.dataset.item) - dialog.Confirm( - f"{item['name']!r} will be deleted, are you sure?", - ok_label="delete", - ok_color="danger", - ).show( - ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item), - cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item), - ) - -# cover - -async def cover_ok(evt, notif_elt, item_elt, item): - # we first need to get a blob of the image - img_elt = item_elt.select_one("img") - # the simplest way is to download it - r = await tmp_aio.ajax("GET", img_elt.src, "blob") - if r.status != 200: - dialog.notification.show( - f"can't retrieve cover: {r.status}: {r.statusText}", - level="error" - ) - return - img_blob = r.response - # now we'll upload it via HTTP Upload, we need a slow - img_name = img_elt.src.rsplit('/', 1)[-1] - img_size = img_blob.size - - slot = await async_bridge.file_http_upload_get_slot( - img_name, - img_size, - '', - files_service - ) - get_url, put_url, headers = slot - # we have the slot, we can upload image - r = await tmp_aio.ajax("PUT", put_url, "", img_blob) - if r.status != 201: - dialog.notification.show( - f"can't upload cover: {r.status}: {r.statusText}", - level="error" - ) - return - extra = {"thumb_url": get_url} - album_name = files_path.rsplit('/', 1)[-1] - await async_bridge.interests_file_sharing_register( - files_service, - "photos", - "", - files_path, - album_name, - JSON.stringify(extra), - ) - dialog.notification.show("Album cover has been changed") - - -def cover_cancel(evt, notif_elt, item_elt, item): - notif_elt.remove() - item_elt.classList.remove("selected_for_action") - - -def on_cover(evt): - evt.stopPropagation() - target = evt.currentTarget - item_elt = DOMNode(target.closest('.item')) - item_elt.classList.add("selected_for_action") - item = JSON.parse(item_elt.dataset.item) - dialog.Confirm( - f"use {item['name']!r} for this album cover?", - ok_label="use as cover", - ).show( - ok_cb=lambda evt, notif_elt: aio.run(cover_ok(evt, notif_elt, item_elt, item)), - cancel_cb=lambda evt, notif_elt: cover_cancel(evt, notif_elt, item_elt, item), - ) - - -# slideshow - -@bind(".photo_thumb_click", "click") -def photo_click(evt): - evt.stopPropagation() - evt.preventDefault() - slideshow = SlideShow() - target = evt.currentTarget - clicked_item_elt = DOMNode(target.closest('.item')) - - slideshow.attach() - for idx, item_elt in enumerate(document.select('.item')): - item = JSON.parse(item_elt.dataset.item) - try: - biggest_thumb = item['extra']['thumbnails'][-1] - thumb_url = f"{cache_path}{biggest_thumb['filename']}" - except (KeyError, IndexError) as e: - print(f"Can't get full screen thumbnail URL: {e}") - thumb_url = None - if item.get("mime_type", "")[:5] == "video": - player = alt_media_player.MediaPlayer( - [item['url']], - poster = thumb_url, - reduce_click_area = True - ) - elt = player.elt - elt.classList.add("slide_video", "no_fullscreen") - slideshow.add_slide( - elt, - item, - options={ - "flags": (alt_media_player.NO_PAGINATION, alt_media_player.NO_SCROLLBAR), - "exit_callback": player.reset, - } - ) - else: - slideshow.add_slide(html.IMG(src=thumb_url or item['url'], Class="slide_img"), item) - if item_elt == clicked_item_elt: - slideshow.index = idx - - -for elt in document.select('.action_delete'): - elt.bind("click", on_delete) -for elt in document.select('.action_cover'): - elt.bind("click", on_cover) - -# manage - - -@bind("#button_manage", "click") -def manage_click(evt): - evt.stopPropagation() - evt.preventDefault() - manager = InvitationManager("photos", {"service": files_service, "path": files_path}) - manager.attach(affiliations=affiliations) - - -# hint -@bind("#hint .click_to_delete", "click") -def remove_hint(evt): - document['hint'].remove() - - -loading.remove_loading_screen()
--- a/libervia/pages/photos/album/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 - - -from sat.core.i18n import D_ -from sat.core.log import getLogger -from libervia.server.constants import Const as C - -log = getLogger(__name__) - -name = "photos_album" -label = D_("Photos Album") -access = C.PAGES_ACCESS_PROFILE -template = "photo/album.html" - - -def parse_url(self, request): - self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="") - - -def prepare_render(self, request): - data = self.get_r_data(request) - data["thumb_limit"] = 800 - data["retrieve_comments"] = True - files_page = self.get_page_by_name("files_list") - return files_page.prepare_render(self, request) - - -def on_data_post(self, request): - blog_page = self.get_page_by_name("blog_view") - return blog_page.on_data_post(self, request)
--- a/libervia/pages/photos/new/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 - -from libervia.server.constants import Const as C -from twisted.internet import defer -from sat.core.log import getLogger -from sat.core.i18n import D_ -from sat.core import exceptions -from sat_frontends.bridge.bridge_frontend import BridgeException - -"""creation of new events""" - -name = "photos_new" -access = C.PAGES_ACCESS_PROFILE -template = "photo/create.html" -log = getLogger(__name__) - - -async def on_data_post(self, request): - request_data = self.get_r_data(request) - profile = self.get_profile(request) - name = self.get_posted_data(request, "name").replace('/', '_') - albums_path = "/albums" - album_path = f"{albums_path}/{name}" - if profile is None: - self.page_error(request, C.HTTP_BAD_REQUEST) - fis_ns = self.host.ns_map["fis"] - http_upload_ns = self.host.ns_map["http_upload"] - entities_services, __, __ = await self.host.bridge_call( - "disco_find_by_features", - [fis_ns, http_upload_ns], - [], - False, - True, - False, - False, - False, - profile - ) - try: - fis_service = next(iter(entities_services)) - except StopIteration: - raise exceptions.DataError(D_( - "You server has no service to create a photo album, please ask your server " - "administrator to add one")) - - try: - await self.host.bridge_call( - "fis_create_dir", - fis_service, - "", - albums_path, - {"access_model": "open"}, - profile - ) - except BridgeException as e: - if e.condition == 'conflict': - pass - else: - log.error(f"Can't create {albums_path} path: {e}") - raise e - - try: - await self.host.bridge_call( - "fis_create_dir", - fis_service, - "", - album_path, - {"access_model": "whitelist"}, - profile - ) - except BridgeException as e: - if e.condition == 'conflict': - pass - else: - log.error(f"Can't create {album_path} path: {e}") - raise e - - await self.host.bridge_call( - "interests_file_sharing_register", - fis_service, - "photos", - "", - album_path, - name, - "", - profile - ) - log.info(f"album {name} created") - request_data["post_redirect_page"] = self.get_page_by_name("photos") - defer.returnValue(C.POST_NO_CONFIRM)
--- a/libervia/pages/photos/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.internet import defer -from sat.core.i18n import _ -from sat.core.log import getLogger - -log = getLogger(__name__) - -name = "photos" -access = C.PAGES_ACCESS_PROFILE -template = "photo/discover.html" - - -@defer.inlineCallbacks -def prepare_render(self, request): - profile = self.get_profile(request) - template_data = request.template_data - namespace = self.host.ns_map["fis"] - if profile is not None: - try: - interests = yield self.host.bridge_call( - "interests_list", "", "", namespace, profile) - except Exception: - log.warning(_("Can't get interests list for {profile}").format( - profile=profile)) - else: - # we only want photo albums - filtered_interests = [] - for interest in interests: - if interest.get('subtype') != 'photos': - continue - path = interest.get('path', '') - path_args = [p for p in path.split('/') if p] - interest["url"] = self.get_sub_page_url( - request, - "photos_album", - interest['service'], - *path_args - ) - filtered_interests.append(interest) - - template_data['interests'] = filtered_interests - - template_data["url_photos_new"] = self.get_sub_page_url(request, "photos_new") - - -@defer.inlineCallbacks -def on_data_post(self, request): - jid_ = self.get_posted_data(request, "jid") - url = self.get_page_by_name("photos_album").get_url(jid_) - self.http_redirect(request, url)
--- a/libervia/pages/register/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from libervia.server import session_iface -from twisted.internet import defer -from sat.core.log import getLogger - -log = getLogger(__name__) - -"""SàT account registration page""" - -name = "register" -access = C.PAGES_ACCESS_PUBLIC -template = "login/register.html" - - -def prepare_render(self, request): - if not self.host.options["allow_registration"]: - self.page_error(request, C.HTTP_FORBIDDEN) - profile = self.get_profile(request) - if profile is not None: - self.page_redirect("/login/logged", request) - template_data = request.template_data - template_data["login_url"] = self.get_page_by_name("login").url - template_data["S_C"] = C # we need server constants in template - - # login error message - session_data = self.host.get_session_data(request, session_iface.IWebSession) - login_error = session_data.pop_page_data(self, "login_error") - if login_error is not None: - template_data["login_error"] = login_error - - # if fields were already filled, we reuse them - for k in ("login", "email", "password"): - template_data[k] = session_data.pop_page_data(self, k) - - -@defer.inlineCallbacks -def on_data_post(self, request): - type_ = self.get_posted_data(request, "type") - if type_ == "register": - login, email, password = self.get_posted_data( - request, ("login", "email", "password") - ) - status = yield self.host.register_new_account(request, login, password, email) - session_data = self.host.get_session_data(request, session_iface.IWebSession) - if status == C.REGISTRATION_SUCCEED: - # we prefill login field for login page - session_data.set_page_data(self.get_page_by_name("login"), "login", login) - # if we have a redirect_url we follow it - self.redirect_or_continue(request) - # else we redirect to login page - self.http_redirect(request, self.get_page_by_name("login").url) - else: - session_data.set_page_data(self, "login_error", status) - l = locals() - for k in ("login", "email", "password"): - # we save fields so user doesn't have to enter them again - session_data.set_page_data(self, k, l[k]) - defer.returnValue(C.POST_NO_CONFIRM) - else: - self.page_error(request, C.HTTP_BAD_REQUEST)
--- a/libervia/pages/u/atom.xml/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 - -redirect = "blog_feed_atom" -name = "user_blog_feed_atom"
--- a/libervia/pages/u/blog/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - - -name = "user_blog" - - -def parse_url(self, request): - # in this subpage, we want path args and query args - # (i.e. what's remaining in URL: filters, id, etc.) - # to be used by blog's url parser, so we don't skip parse_url - data = self.get_r_data(request) - service = data["service"] - self.page_redirect( - "blog_view", request, skip_parse_url=False, path_args=[service.full(), "@"] - )
--- a/libervia/pages/u/page_meta.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 - - -from libervia.server.constants import Const as C -from twisted.internet import defer -from twisted.words.protocols.jabber import jid - -"""page used to target a user profile, e.g. for public blog""" - -name = "user" -access = C.PAGES_ACCESS_PUBLIC # can be a callable -template = "blog/articles.html" -url_cache = True - - -@defer.inlineCallbacks -def parse_url(self, request): - try: - prof_requested = self.next_path(request) - except IndexError: - self.page_error(request) - - data = self.get_r_data(request) - - target_profile = yield self.host.bridge_call("profile_name_get", prof_requested) - request.template_data["target_profile"] = target_profile - target_jid = yield self.host.bridge_call( - "param_get_a_async", "JabberID", "Connection", "value", profile_key=target_profile - ) - target_jid = jid.JID(target_jid) - data["service"] = target_jid - - # if URL is parsed here, we'll have atom.xml available and we need to - # add the link to the page - atom_url = self.get_sub_page_url(request, 'user_blog_feed_atom') - request.template_data['atom_url'] = atom_url - request.template_data.setdefault('links', []).append({ - "href": atom_url, - "type": "application/atom+xml", - "rel": "alternate", - "title": "{target_profile}'s blog".format(target_profile=target_profile)}) - -def add_breadcrumb(self, request, breadcrumbs): - # we don't want a breadcrumb here - pass - - -@defer.inlineCallbacks -def prepare_render(self, request): - data = self.get_r_data(request) - self.check_cache( - request, C.CACHE_PUBSUB, service=data["service"], node=None, short="microblog" - ) - self.page_redirect("blog_view", request) - -def on_data_post(self, request): - return self.get_page_by_name("blog_view").on_data_post(self, request)
--- a/libervia/server/classes.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 - - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""Useful genertic classes used in Libervia""" - - -from collections import namedtuple - -WebsocketMeta = namedtuple("WebsocketMeta", ("url", "token", "debug")) -Notification = namedtuple("Notification", ("message", "level")) -Script = namedtuple("Script", ("src", "type", "content"), defaults=(None, None, ""))
--- a/libervia/server/constants.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a SàT frontend -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from ..common import constants - - -class Const(constants.Const): - - APP_NAME = "Libervia Web" - APP_COMPONENT = "web" - APP_NAME_ALT = APP_NAME - APP_NAME_FILE = "libervia_web" - CONFIG_SECTION = APP_COMPONENT.lower() - # the Libervia profile that is used for public operations (when nobody is connected) - SERVICE_PROFILE = "libervia" - - SESSION_TIMEOUT = 7200 # Session's timeout, after that the user will be disconnected - HTML_DIR = "html/" - THEMES_DIR = "themes/" - THEMES_URL = "themes" - MEDIA_DIR = "media/" - CARDS_DIR = "games/cards/tarot" - PAGES_DIR = "pages" - TASKS_DIR = "tasks" - LIBERVIA_CACHE = "libervia" - SITE_NAME_DEFAULT = "default" - # generated files will be accessible there - BUILD_DIR = "__b" - BUILD_DIR_DYN = "dyn" - # directory where build files are served to the client - PRODUCTION_BUILD_DIR = "sites" - # directory used for files needed temporarily (e.g. for compiling other files) - DEV_BUILD_DIR = "dev_build" - - TPL_RESOURCE = '_t' - - ERRNUM_BRIDGE_ERRBACK = 0 # FIXME - ERRNUM_LIBERVIA = 0 # FIXME - - # Security limit for Libervia (get/set params) - SECURITY_LIMIT = 5 - - # Security limit for Libervia server_side - SERVER_SECURITY_LIMIT = constants.Const.NO_SECURITY_LIMIT - - # keys for cache values we can get from browser - ALLOWED_ENTITY_DATA = {"avatar", "nick"} - - STATIC_RSM_MAX_LIMIT = 100 - STATIC_RSM_MAX_DEFAULT = 10 - STATIC_RSM_MAX_COMMENTS_DEFAULT = 10 - - ## Libervia pages ## - PAGES_META_FILE = "page_meta.py" - PAGES_BROWSER_DIR = "_browser" - PAGES_BROWSER_META_FILE = "browser_meta.json" - PAGES_ACCESS_NONE = ( - "none" - ) # no access to this page (using its path will return a 404 error) - PAGES_ACCESS_PUBLIC = "public" - PAGES_ACCESS_PROFILE = ( - "profile" - ) # a session with an existing profile must be started - PAGES_ACCESS_ADMIN = "admin" # only profiles set in admins_list can access the page - PAGES_ACCESS_ALL = ( - PAGES_ACCESS_NONE, - PAGES_ACCESS_PUBLIC, - PAGES_ACCESS_PROFILE, - PAGES_ACCESS_ADMIN, - ) - # names of the page to use for menu - DEFAULT_MENU = [ - "login", - "chat", - "blog", - "forums", - "photos", - "files", - "calendar", - "events", - "lists", - "merge-requests", - "calls" - # XXX: app is not available anymore since removal of pyjamas code with Python 3 - # port. It should come back at a later point with an alternative (Brython - # probably). - ] - - ## Session flags ## - FLAG_CONFIRM = "CONFIRM" - - ## Data post ## - POST_NO_CONFIRM = "POST_NO_CONFIRM" - - ## HTTP methods ## - HTTP_METHOD_GET = b"GET" - HTTP_METHOD_POST = b"POST" - - ## HTTP codes ## - HTTP_SEE_OTHER = 303 - HTTP_NOT_MODIFIED = 304 - HTTP_BAD_REQUEST = 400 - HTTP_UNAUTHORIZED = 401 - HTTP_FORBIDDEN = 403 - HTTP_NOT_FOUND = 404 - HTTP_INTERNAL_ERROR = 500 - HTTP_PROXY_ERROR = 502 - HTTP_SERVICE_UNAVAILABLE = 503 - - ## HTTP HEADERS ## - H_FORWARDED = "Forwarded" - H_X_FORWARDED_FOR = "X-Forwarded-For" - H_X_FORWARDED_HOST = "X-Forwarded-Host" - H_X_FORWARDED_PROTO = "X-Forwarded-Proto" - - - ## Cache ## - CACHE_PUBSUB = 0 - - ## Date/Time ## - HTTP_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") - HTTP_MONTH = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", - "Nov", "Dec")
--- a/libervia/server/html_tools.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 - - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -def sanitize_html(text): - """Sanitize HTML by escaping everything""" - # this code comes from official python wiki: http://wiki.python.org/moin/EscapingHtml - html_escape_table = { - "&": "&", - '"': """, - "'": "'", - ">": ">", - "<": "<", - } - - return "".join(html_escape_table.get(c, c) for c in text) - - -def convert_new_lines_to_xhtml(text): - return text.replace("\n", "<br/>")
--- a/libervia/server/launcher.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""Script launching Libervia server""" - -from sat.core import launcher -from libervia.server.constants import Const as C - - -class Launcher(launcher.Launcher): - APP_NAME=C.APP_NAME - APP_NAME_FILE=C.APP_NAME_FILE - - -if __name__ == '__main__': - Launcher.run()
--- a/libervia/server/pages.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1860 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from __future__ import annotations -import copy -from functools import reduce -import hashlib -import json -import os.path -from pathlib import Path -import time -import traceback -from typing import List, Optional, Union -import urllib.error -import urllib.parse -import urllib.request -import uuid - -from twisted.internet import defer -from twisted.python import failure -from twisted.python.filepath import FilePath -from twisted.web import server -from twisted.web import resource as web_resource -from twisted.web import util as web_util -from twisted.words.protocols.jabber import jid - -from sat.core import exceptions -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import date_utils -from sat.tools.common import utils -from sat.tools.common import data_format -from sat.tools.utils import as_deferred -from sat_frontends.bridge.bridge_frontend import BridgeException - -from . import session_iface -from .classes import WebsocketMeta -from .classes import Script -from .constants import Const as C -from .resources import LiberviaRootResource -from .utils import SubPage, quote - -log = getLogger(__name__) - - -class CacheBase(object): - def __init__(self): - self._created = time.time() - self._last_access = self._created - - @property - def created(self): - return self._created - - @property - def last_access(self): - return self._last_access - - @last_access.setter - def last_access(self, timestamp): - self._last_access = timestamp - - -class CachePage(CacheBase): - def __init__(self, rendered): - super(CachePage, self).__init__() - self._created = time.time() - self._last_access = self._created - self._rendered = rendered - self._hash = hashlib.sha256(rendered).hexdigest() - - @property - def rendered(self): - return self._rendered - - @property - def hash(self): - return self._hash - - -class CacheURL(CacheBase): - def __init__(self, request): - super(CacheURL, self).__init__() - try: - self._data = copy.deepcopy(request.data) - except AttributeError: - self._data = {} - self._template_data = copy.deepcopy(request.template_data) - self._prepath = request.prepath[:] - self._postpath = request.postpath[:] - del self._template_data["csrf_token"] - - def use(self, request): - self.last_access = time.time() - request.data = copy.deepcopy(self._data) - request.template_data.update(copy.deepcopy(self._template_data)) - request.prepath = self._prepath[:] - request.postpath = self._postpath[:] - - -class LiberviaPage(web_resource.Resource): - isLeaf = True # we handle subpages ourself - cache = {} - # Set of tuples (service/node/sub_id) of nodes subscribed for caching - # sub_id can be empty string if not handled by service - cache_pubsub_sub = set() - - def __init__( - self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None, - access=None, dynamic=True, parse_url=None, add_breadcrumb=None, - prepare_render=None, render=None, template=None, on_data_post=None, on_data=None, - url_cache=False, replace_on_conflict=False - ): - """Initiate LiberviaPage instance - - LiberviaPages are the main resources of Libervia, using easy to set python files - The non mandatory arguments are the variables found in page_meta.py - @param host(Libervia): the running instance of Libervia - @param vhost_root(web_resource.Resource): root resource of the virtual host which - handle this page. - @param root_dir(Path): absolute file path of the page - @param url(unicode): relative URL to the page - this URL may not be valid, as pages may require path arguments - @param name(unicode, None): if not None, a unique name to identify the page - can then be used for e.g. redirection - "/" is not allowed in names (as it can be used to construct URL paths) - @param redirect(unicode, None): if not None, this page will be redirected. - A redirected parameter is used as in self.page_redirect. - parse_url will not be skipped - using this redirect parameter is called "full redirection" - using self.page_redirect is called "partial redirection" (because some - rendering method can still be used, e.g. parse_url) - @param access(unicode, None): permission needed to access the page - None means public access. - Pages inherit from parent pages: e.g. if a "settings" page is restricted - to admins, and if "settings/blog" is public, it still can only be accessed by - admins. See C.PAGES_ACCESS_* for details - @param dynamic(bool): if True, activate websocket for bidirectional communication - @param parse_url(callable, None): if set it will be called to handle the URL path - after this method, the page will be rendered if noting is left in path - (request.postpath) else a the request will be transmitted to a subpage - @param add_breadcrumb(callable, None): if set, manage the breadcrumb data for this - page, otherwise it will be set automatically from page name or label. - @param prepare_render(callable, None): if set, will be used to prepare the - rendering. That often means gathering data using the bridge - @param render(callable, None): if template is not set, this method will be - called and what it returns will be rendered. - This method is mutually exclusive with template and must return a unicode - string. - @param template(unicode, None): path to the template to render. - This method is mutually exclusive with render - @param on_data_post(callable, None): method to call when data is posted - None if data post is not handled - "continue" if data post is not handled there, and we must not interrupt - workflow (i.e. it's handled in "render" method). - otherwise, on_data_post can return a string with following value: - - C.POST_NO_CONFIRM: confirm flag will not be set - on_data_post can raise following exceptions: - - exceptions.DataError: value is incorrect, message will be displayed - as a notification - @param on_data(callable, None): method to call when dynamic data is sent - this method is used with Libervia's websocket mechanism - @param url_cache(boolean): if set, result of parse_url is cached (per profile). - Useful when costly calls (e.g. network) are done while parsing URL. - @param replace_on_conflict(boolean): if True, don't raise ConflictError if a - page of this name already exists, but replace it - """ - - web_resource.Resource.__init__(self) - self.host = host - self.vhost_root = vhost_root - self.root_dir = root_dir - self.url = url - self.name = name - self.label = label - self.dyn_data = {} - if name is not None: - if (name in self.named_pages - and not (replace_on_conflict and self.named_pages[name].url == url)): - raise exceptions.ConflictError( - _('a Libervia page named "{}" already exists'.format(name))) - if "/" in name: - raise ValueError(_('"/" is not allowed in page names')) - if not name: - raise ValueError(_("a page name can't be empty")) - self.named_pages[name] = self - if access is None: - access = C.PAGES_ACCESS_PUBLIC - if access not in ( - C.PAGES_ACCESS_PUBLIC, - C.PAGES_ACCESS_PROFILE, - C.PAGES_ACCESS_NONE, - ): - raise NotImplementedError( - _("{} access is not implemented yet").format(access) - ) - self.access = access - self.dynamic = dynamic - if redirect is not None: - # only page access and name make sense in case of full redirection - # so we check that rendering methods/values are not set - if not all( - lambda x: x is not None - for x in (parse_url, prepare_render, render, template) - ): - raise ValueError( - _("you can't use full page redirection with other rendering" - "method, check self.page_redirect if you need to use them")) - self.redirect = redirect - else: - self.redirect = None - self.parse_url = parse_url - self.add_breadcrumb = add_breadcrumb - self.prepare_render = prepare_render - self.template = template - self.render_method = render - self.on_data_post = on_data_post - self.on_data = on_data - self.url_cache = url_cache - if access == C.PAGES_ACCESS_NONE: - # none pages just return a 404, no further check is needed - return - if template is not None and render is not None: - log.error(_("render and template methods can't be used at the same time")) - - # if not None, next rendering will be cached - # it must then contain a list of the the keys to use (without the page instance) - # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node] - self._do_cache = None - - def __str__(self): - return "LiberviaPage {name} at {url} (vhost: {vhost_root})".format( - name=self.name or "<anonymous>", url=self.url, vhost_root=self.vhost_root) - - @property - def named_pages(self): - return self.vhost_root.named_pages - - @property - def uri_callbacks(self): - return self.vhost_root.uri_callbacks - - @property - def pages_redirects(self): - return self.vhost_root.pages_redirects - - @property - def cached_urls(self): - return self.vhost_root.cached_urls - - @property - def main_menu(self): - return self.vhost_root.main_menu - - @property - def default_theme(self): - return self.vhost_root.default_theme - - - @property - def site_themes(self): - return self.vhost_root.site_themes - - @staticmethod - def create_page(host, meta_path, vhost_root, url_elts, replace_on_conflict=False): - """Create a LiberviaPage instance - - @param meta_path(Path): path to the page_meta.py file - @param vhost_root(resource.Resource): root resource of the virtual host - @param url_elts(list[unicode]): list of path element from root site to this page - @param replace_on_conflict(bool): same as for [LiberviaPage] - @return (tuple[dict, LiberviaPage]): tuple with: - - page_data: dict containing data of the page - - libervia_page: created resource - """ - dir_path = meta_path.parent - page_data = {"__name__": ".".join(["page"] + url_elts)} - # we don't want to force the presence of __init__.py - # so we use execfile instead of import. - # TODO: when moved to Python 3, __init__.py is not mandatory anymore - # so we can switch to import - exec(compile(open(meta_path, "rb").read(), meta_path, 'exec'), page_data) - return page_data, LiberviaPage( - host=host, - vhost_root=vhost_root, - root_dir=dir_path, - url="/" + "/".join(url_elts), - name=page_data.get("name"), - label=page_data.get("label"), - redirect=page_data.get("redirect"), - access=page_data.get("access"), - dynamic=page_data.get("dynamic", True), - parse_url=page_data.get("parse_url"), - add_breadcrumb=page_data.get("add_breadcrumb"), - prepare_render=page_data.get("prepare_render"), - render=page_data.get("render"), - template=page_data.get("template"), - on_data_post=page_data.get("on_data_post"), - on_data=page_data.get("on_data"), - url_cache=page_data.get("url_cache", False), - replace_on_conflict=replace_on_conflict - ) - - @staticmethod - def create_browser_data( - vhost_root, - resource: Optional[LiberviaPage], - browser_path: Path, - path_elts: Optional[List[str]], - engine: str = "brython" - ) -> None: - """create and store data for browser dynamic code""" - dyn_data = { - "path": browser_path, - "url_hash": ( - hashlib.sha256('/'.join(path_elts).encode()).hexdigest() - if path_elts is not None else None - ), - } - browser_meta_path = browser_path / C.PAGES_BROWSER_META_FILE - if browser_meta_path.is_file(): - with browser_meta_path.open() as f: - browser_meta = json.load(f) - utils.recursive_update(vhost_root.browser_modules, browser_meta) - if resource is not None: - utils.recursive_update(resource.dyn_data, browser_meta) - - init_path = browser_path / '__init__.py' - if init_path.is_file(): - vhost_root.browser_modules.setdefault( - engine, []).append(dyn_data) - if resource is not None: - resource.dyn_data[engine] = dyn_data - elif path_elts is None: - try: - next(browser_path.glob('*.py')) - except StopIteration: - # no python file, nothing for Brython - pass - else: - vhost_root.browser_modules.setdefault( - engine, []).append(dyn_data) - - - @classmethod - def import_pages(cls, host, vhost_root, root_path=None, _parent=None, _path=None, - _extra_pages=False): - """Recursively import Libervia pages - - @param host(Libervia): Libervia instance - @param vhost_root(LiberviaRootResource): root of this VirtualHost - @param root_path(Path, None): use this root path instead of vhost_root's one - Used to add default site pages to external sites - @param _parent(Resource, None): _parent page. Do not set yourself, this is for - internal use only - @param _path(list(unicode), None): current path. Do not set yourself, this is for - internal use only - @param _extra_pages(boolean): set to True when extra pages are used (i.e. - root_path is set). Do not set yourself, this is for internal use only - """ - if _path is None: - _path = [] - if _parent is None: - if root_path is None: - root_dir = vhost_root.site_path / C.PAGES_DIR - else: - root_dir = root_path / C.PAGES_DIR - _extra_pages = True - _parent = vhost_root - root_browser_path = root_dir / C.PAGES_BROWSER_DIR - if root_browser_path.is_dir(): - cls.create_browser_data(vhost_root, None, root_browser_path, None) - else: - root_dir = _parent.root_dir - - for d in os.listdir(root_dir): - dir_path = root_dir / d - if not dir_path.is_dir(): - continue - if _extra_pages and d in _parent.children: - log.debug(_("[{host_name}] {path} is already present, ignoring it") - .format(host_name=vhost_root.host_name, path='/'.join(_path+[d]))) - continue - meta_path = dir_path / C.PAGES_META_FILE - if meta_path.is_file(): - new_path = _path + [d] - try: - page_data, resource = cls.create_page( - host, meta_path, vhost_root, new_path) - except exceptions.ConflictError as e: - if _extra_pages: - # extra pages are discarded if there is already an existing page - continue - else: - raise e - _parent.putChild(str(d).encode(), resource) - log_msg = ("[{host_name}] Added /{path} page".format( - host_name=vhost_root.host_name, - path="[…]/".join(new_path))) - if _extra_pages: - log.debug(log_msg) - else: - log.info(log_msg) - if "uri_handlers" in page_data: - if not isinstance(page_data, dict): - log.error(_("uri_handlers must be a dict")) - else: - for uri_tuple, cb_name in page_data["uri_handlers"].items(): - if len(uri_tuple) != 2 or not isinstance(cb_name, str): - log.error(_("invalid uri_tuple")) - continue - if not _extra_pages: - log.info(_("setting {}/{} URIs handler") - .format(*uri_tuple)) - try: - cb = page_data[cb_name] - except KeyError: - log.error(_("missing {name} method to handle {1}/{2}") - .format(name=cb_name, *uri_tuple)) - continue - else: - resource.register_uri(uri_tuple, cb) - - LiberviaPage.import_pages( - host, vhost_root, _parent=resource, _path=new_path, - _extra_pages=_extra_pages) - # now we check if there is some code for browser - browser_path = dir_path / C.PAGES_BROWSER_DIR - if browser_path.is_dir(): - cls.create_browser_data(vhost_root, resource, browser_path, new_path) - - @classmethod - def on_file_change( - cls, - host, - file_path: FilePath, - flags: List[str], - site_root: LiberviaRootResource, - site_path: Path - ) -> None: - """Method triggered by file_watcher when something is changed in files - - This method is used in dev mode to reload pages when needed - @param file_path: path of the file which triggered the event - @param flags: human readable flags of the event (from - internet.inotify) - @param site_root: root of the site - @param site_path: absolute path of the site - """ - if flags == ['create']: - return - path = Path(file_path.path.decode()) - base_name = path.name - if base_name != "page_meta.py": - # we only handle libervia pages - return - - log.debug("{flags} event(s) received for {file_path}".format( - flags=", ".join(flags), file_path=file_path)) - - dir_path = path.parent - - if dir_path == site_path: - return - - if not site_path in dir_path.parents: - raise exceptions.InternalError("watched file should be in a subdirectory of site path") - - path_elts = list(dir_path.relative_to(site_path).parts) - - if path_elts[0] == C.PAGES_DIR: - # a page has been modified - del path_elts[0] - if not path_elts: - # we need at least one element to parse - return - # we retrieve page by starting from site root and finding each path element - parent = page = site_root - new_page = False - for idx, child_name in enumerate(path_elts): - child_name = child_name.encode() - try: - try: - page = page.original.children[child_name] - except AttributeError: - page = page.children[child_name] - except KeyError: - if idx != len(path_elts)-1: - # a page has been created in a subdir when one or more - # page_meta.py are missing on the way - log.warning(_("Can't create a page at {path}, missing parents") - .format(path=path)) - return - new_page = True - else: - if idx<len(path_elts)-1: - try: - parent = page.original - except AttributeError: - parent = page - - try: - # we (re)create a page with the new/modified code - __, resource = cls.create_page(host, path, site_root, path_elts, - replace_on_conflict=True) - if not new_page: - try: - resource.children = page.original.children - except AttributeError: - # FIXME: this .original handling madness is due to EncodingResourceWrapper - # EncodingResourceWrapper should probably be removed - resource.children = page.children - except Exception as e: - log.warning(_("Can't create page: {reason}").format(reason=e)) - else: - url_elt = path_elts[-1].encode() - if not new_page: - # the page was already existing, we remove it - del parent.children[url_elt] - # we can now add the new page - parent.putChild(url_elt, resource) - - # is there any browser data to create? - browser_path = resource.root_dir / C.PAGES_BROWSER_DIR - if browser_path.is_dir(): - cls.create_browser_data( - resource.vhost_root, - resource, - browser_path, - resource.url.split('/') - ) - - if new_page: - log.info(_("{page} created").format(page=resource)) - else: - log.info(_("{page} reloaded").format(page=resource)) - - def check_csrf(self, request): - session = self.host.get_session_data( - request, session_iface.IWebSession - ) - if session.profile is None: - # CSRF doesn't make sense when no user is logged - log.debug("disabling CSRF check because service profile is used") - return - csrf_token = session.csrf_token - given_csrf = request.getHeader("X-Csrf-Token") - if given_csrf is None: - try: - given_csrf = self.get_posted_data(request, "csrf_token") - except KeyError: - pass - if given_csrf is None or given_csrf != csrf_token: - log.warning( - _("invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format( - url=request.uri, ip=request.getClientIP() - ) - ) - self.page_error(request, C.HTTP_FORBIDDEN) - - def expose_to_scripts( - self, - request: server.Request, - **kwargs: str - ) -> None: - """Make a local variable available to page script as a global variable - - No check is done for conflicting name, use this carefully - """ - template_data = request.template_data - scripts = template_data.setdefault("scripts", utils.OrderedSet()) - for name, value in kwargs.items(): - if value is None: - value = "null" - elif isinstance(value, str): - # FIXME: workaround for subtype used by python-dbus (dbus.String) - # to be removed when we get rid of python-dbus - value = repr(str(value)) - else: - value = repr(value) - scripts.add(Script(content=f"var {name}={value};")) - - def register_uri(self, uri_tuple, get_uri_cb): - """Register a URI handler - - @param uri_tuple(tuple[unicode, unicode]): type or URIs handler - type/subtype as returned by tools/common/parse_xmpp_uri - or type/None to handle all subtypes - @param get_uri_cb(callable): method which take uri_data dict as only argument - and return absolute path with correct arguments or None if the page - can't handle this URL - """ - if uri_tuple in self.uri_callbacks: - log.info(_("{}/{} URIs are already handled, replacing by the new handler") - .format( *uri_tuple)) - self.uri_callbacks[uri_tuple] = (self, get_uri_cb) - - def config_get(self, key, default=None, value_type=None): - return self.host.config_get(self.vhost_root, key=key, default=default, - value_type=value_type) - - def get_build_path(self, session_data): - return session_data.cache_dir + self.vhost.site_name - - def get_page_by_name(self, name): - return self.vhost_root.get_page_by_name(name) - - def get_page_path_from_uri(self, uri): - return self.vhost_root.get_page_path_from_uri(uri) - - def get_page_redirect_url(self, request, page_name="login", url=None): - """generate URL for a page with redirect_url parameter set - - mainly used for login page with redirection to current page - @param request(server.Request): current HTTP request - @param page_name(unicode): name of the page to go - @param url(None, unicode): url to redirect to - None to use request path (i.e. current page) - @return (unicode): URL to use - """ - return "{root_url}?redirect_url={redirect_url}".format( - root_url=self.get_page_by_name(page_name).url, - redirect_url=urllib.parse.quote_plus(request.uri) - if url is None - else url.encode("utf-8"), - ) - - def get_url(self, *args: str, **kwargs: str) -> str: - """retrieve URL of the page set arguments - - @param *args: arguments to add to the URL as path elements empty or None - arguments will be ignored - @param **kwargs: query parameters - """ - url_args = [quote(a) for a in args if a] - - if self.name is not None and self.name in self.pages_redirects: - # we check for redirection - redirect_data = self.pages_redirects[self.name] - args_hash = tuple(args) - for limit in range(len(args), -1, -1): - current_hash = args_hash[:limit] - if current_hash in redirect_data: - url_base = redirect_data[current_hash] - remaining = args[limit:] - remaining_url = "/".join(remaining) - url = urllib.parse.urljoin(url_base, remaining_url) - break - else: - url = os.path.join(self.url, *url_args) - else: - url = os.path.join(self.url, *url_args) - - if kwargs: - encoded = urllib.parse.urlencode( - {k: v for k, v in kwargs.items()} - ) - url += f"?{encoded}" - - return self.host.check_redirection( - self.vhost_root, - url - ) - - def get_current_url(self, request): - """retrieve URL used to access this page - - @return(unicode): current URL - """ - # we get url in the following way (splitting request.path instead of using - # request.prepath) because request.prepath may have been modified by - # redirection (if redirection args have been specified), while path reflect - # the real request - - # we ignore empty path elements (i.e. double '/' or '/' at the end) - path_elts = [p for p in request.path.decode('utf-8').split("/") if p] - - if request.postpath: - if not request.postpath[-1]: - # we remove trailing slash - request.postpath = request.postpath[:-1] - if request.postpath: - # get_sub_page_url must return subpage from the point where - # the it is called, so we have to remove remanining - # path elements - path_elts = path_elts[: -len(request.postpath)] - - return "/" + "/".join(path_elts) - - def get_param_url(self, request, **kwargs): - """use URL of current request but modify the parameters in query part - - **kwargs(dict[str, unicode]): argument to use as query parameters - @return (unicode): constructed URL - """ - current_url = self.get_current_url(request) - if kwargs: - encoded = urllib.parse.urlencode( - {k: v for k, v in kwargs.items()} - ) - current_url = current_url + "?" + encoded - return current_url - - def get_sub_page_by_name(self, subpage_name, parent=None): - """retrieve a subpage and its path using its name - - @param subpage_name(unicode): name of the sub page - it must be a direct children of parent page - @param parent(LiberviaPage, None): parent page - None to use current page - @return (tuple[str, LiberviaPage]): page subpath and instance - @raise exceptions.NotFound: no page has been found - """ - if parent is None: - parent = self - for path, child in parent.children.items(): - try: - child_name = child.name - except AttributeError: - # LiberviaPages have a name, but maybe this is an other Resource - continue - if child_name == subpage_name: - return path.decode('utf-8'), child - raise exceptions.NotFound( - _("requested sub page has not been found ({subpage_name})").format( - subpage_name=subpage_name)) - - def get_sub_page_url(self, request, page_name, *args): - """retrieve a page in direct children and build its URL according to request - - request's current path is used as base (at current parsing point, - i.e. it's more prepath than path). - Requested page is checked in children and an absolute URL is then built - by the resulting combination. - This method is useful to construct absolute URLs for children instead of - using relative path, which may not work in subpages, and are linked to the - names of directories (i.e. relative URL will break if subdirectory is renamed - while get_sub_page_url won't as long as page_name is consistent). - Also, request.path is used, keeping real path used by user, - and potential redirections. - @param request(server.Request): current HTTP request - @param page_name(unicode): name of the page to retrieve - it must be a direct children of current page - @param *args(list[unicode]): arguments to add as path elements - if an arg is None, it will be ignored - @return (unicode): absolute URL to the sub page - """ - current_url = self.get_current_url(request) - path, child = self.get_sub_page_by_name(page_name) - return os.path.join( - "/", current_url, path, *[quote(a) for a in args if a is not None] - ) - - def get_url_by_names(self, named_path): - """Retrieve URL from pages names and arguments - - @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list - of tuples of 2 items: - - first item is page name - - second item is list of path arguments of this page - @return (unicode): URL to the requested page with given path arguments - @raise exceptions.NotFound: one of the page was not found - """ - current_page = None - path = [] - for page_name, page_args in named_path: - if current_page is None: - current_page = self.get_page_by_name(page_name) - path.append(current_page.get_url(*page_args)) - else: - sub_path, current_page = self.get_sub_page_by_name( - page_name, parent=current_page - ) - path.append(sub_path) - if page_args: - path.extend([quote(a) for a in page_args]) - return self.host.check_redirection(self.vhost_root, "/".join(path)) - - def get_url_by_path(self, *args): - """Generate URL by path - - this method as a similar effect as get_url_by_names, but it is more readable - by using SubPage to get pages instead of using tuples - @param *args: path element: - - if unicode, will be used as argument - - if util.SubPage instance, must be the name of a subpage - @return (unicode): generated path - """ - args = list(args) - if not args: - raise ValueError("You must specify path elements") - # root page is the one needed to construct the base of the URL - # if first arg is not a SubPage instance, we use current page - if not isinstance(args[0], SubPage): - root = self - else: - root = self.get_page_by_name(args.pop(0)) - # we keep track of current page to check subpage - current_page = root - url_elts = [] - arguments = [] - while True: - while args and not isinstance(args[0], SubPage): - arguments.append(quote(args.pop(0))) - if not url_elts: - url_elts.append(root.get_url(*arguments)) - else: - url_elts.extend(arguments) - if not args: - break - else: - path, current_page = current_page.get_sub_page_by_name(args.pop(0)) - arguments = [path] - return self.host.check_redirection(self.vhost_root, "/".join(url_elts)) - - def getChildWithDefault(self, path, request): - # we handle children ourselves - raise exceptions.InternalError( - "this method should not be used with LiberviaPage" - ) - - def next_path(self, request): - """get next URL path segment, and update request accordingly - - will move first segment of postpath in prepath - @param request(server.Request): current HTTP request - @return (unicode): unquoted segment - @raise IndexError: there is no segment left - """ - pathElement = request.postpath.pop(0) - request.prepath.append(pathElement) - return urllib.parse.unquote(pathElement.decode('utf-8')) - - def _filter_path_value(self, value, handler, name, request): - """Modify a path value according to handler (see [get_path_args])""" - if handler in ("@", "@jid") and value == "@": - value = None - - if handler in ("", "@"): - if value is None: - return "" - elif handler in ("jid", "@jid"): - if value: - try: - return jid.JID(value) - except (RuntimeError, jid.InvalidFormat): - log.warning(_("invalid jid argument: {value}").format(value=value)) - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - return "" - else: - return handler(self, value, name, request) - - return value - - def get_path_args(self, request, names, min_args=0, **kwargs): - """get several path arguments at once - - Arguments will be put in request data. - Missing arguments will have None value - @param names(list[unicode]): list of arguments to get - @param min_args(int): if less than min_args are found, PageError is used with - C.HTTP_BAD_REQUEST - Use 0 to ignore - @param **kwargs: special value or optional callback to use for arguments - names of the arguments must correspond to those in names - special values may be: - - '': use empty string instead of None when no value is specified - - '@': if value of argument is empty or '@', empty string will be used - - 'jid': value must be converted to jid.JID if it exists, else empty - string is used - - '@jid': if value of arguments is empty or '@', empty string will be - used, else it will be converted to jid - """ - data = self.get_r_data(request) - - for idx, name in enumerate(names): - if name[0] == "*": - value = data[name[1:]] = [] - while True: - try: - value.append(self.next_path(request)) - except IndexError: - idx -= 1 - break - else: - idx += 1 - else: - try: - value = data[name] = self.next_path(request) - except IndexError: - data[name] = None - idx -= 1 - break - - values_count = idx + 1 - if values_count < min_args: - log.warning(_("Missing arguments in URL (got {count}, expected at least " - "{min_args})").format(count=values_count, min_args=min_args)) - self.page_error(request, C.HTTP_BAD_REQUEST) - - for name in names[values_count:]: - data[name] = None - - for name, handler in kwargs.items(): - if name[0] == "*": - data[name] = [ - self._filter_path_value(v, handler, name, request) for v in data[name] - ] - else: - data[name] = self._filter_path_value(data[name], handler, name, request) - - ## Pagination/Filtering ## - - def get_pubsub_extra(self, request, page_max=10, params=None, extra=None, - order_by=C.ORDER_BY_CREATION): - """Set extra dict to retrieve PubSub items corresponding to URL parameters - - Following parameters are used: - - after: set rsm_after with ID of item - - before: set rsm_before with ID of item - @param request(server.Request): current HTTP request - @param page_max(int): required number of items per page - @param params(None, dict[unicode, list[unicode]]): params as returned by - self.get_all_posted_data. - None to parse URL automatically - @param extra(None, dict): extra dict to use, or None to use a new one - @param order_by(unicode, None): key to order by - None to not specify order - @return (dict): fill extra data - """ - if params is None: - params = self.get_all_posted_data(request, multiple=False) - if extra is None: - extra = {} - else: - assert not {"rsm_max", "rsm_after", "rsm_before", - C.KEY_ORDER_BY}.intersection(list(extra.keys())) - extra["rsm_max"] = params.get("page_max", str(page_max)) - if order_by is not None: - extra[C.KEY_ORDER_BY] = order_by - if 'after' in params: - extra['rsm_after'] = params['after'] - elif 'before' in params: - extra['rsm_before'] = params['before'] - else: - # RSM returns list in order (oldest first), but we want most recent first - # so we start by the end - extra['rsm_before'] = "" - return extra - - def set_pagination(self, request: server.Request, pubsub_data: dict) -> None: - """Add to template_data if suitable - - "previous_page_url" and "next_page_url" will be added using respectively - "before" and "after" URL parameters - @param request: current HTTP request - @param pubsub_data: pubsub metadata - """ - template_data = request.template_data - extra = {} - try: - rsm = pubsub_data["rsm"] - last_id = rsm["last"] - except KeyError: - # no pagination available - return - - # if we have a search query, we must keep it - search = self.get_posted_data(request, 'search', raise_on_missing=False) - if search is not None: - extra['search'] = search.strip() - - # same for page_max - page_max = self.get_posted_data(request, 'page_max', raise_on_missing=False) - if page_max is not None: - extra['page_max'] = page_max - - if rsm.get("index", 1) > 0: - # We only show previous button if it's not the first page already. - # If we have no index, we default to display the button anyway - # as we can't know if we are on the first page or not. - first_id = rsm["first"] - template_data['previous_page_url'] = self.get_param_url( - request, before=first_id, **extra) - if not pubsub_data["complete"]: - # we also show the page next button if complete is None because we - # can't know where we are in the feed in this case. - template_data['next_page_url'] = self.get_param_url( - request, after=last_id, **extra) - - - ## Cache handling ## - - def _set_cache_headers(self, request, cache): - """Set ETag and Last-Modified HTTP headers, used for caching""" - request.setHeader("ETag", cache.hash) - last_modified = self.host.get_http_date(cache.created) - request.setHeader("Last-Modified", last_modified) - - def _check_cache_headers(self, request, cache): - """Check if a cache condition is set on the request - - if condition is valid, C.HTTP_NOT_MODIFIED is returned - """ - etag_match = request.getHeader("If-None-Match") - if etag_match is not None: - if cache.hash == etag_match: - self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True) - else: - modified_match = request.getHeader("If-Modified-Since") - if modified_match is not None: - modified = date_utils.date_parse(modified_match) - if modified >= int(cache.created): - self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True) - - def check_cache_subscribe_cb(self, sub_id, service, node): - self.cache_pubsub_sub.add((service, node, sub_id)) - - def check_cache_subscribe_eb(self, failure_, service, node): - log.warning(_("Can't subscribe to node: {msg}").format(msg=failure_)) - # FIXME: cache must be marked as unusable here - - def ps_node_watch_add_eb(self, failure_, service, node): - log.warning(_("Can't add node watched: {msg}").format(msg=failure_)) - - def use_cache(self, request: server.Request) -> bool: - """Indicate if the cache should be used - - test request header to see if it is requested to skip the cache - @return: True if cache should be used - """ - return request.getHeader('cache-control') != 'no-cache' - - def check_cache(self, request, cache_type, **kwargs): - """check if a page is in cache and return cached version if suitable - - this method may perform extra operation to handle cache (e.g. subscribing to a - pubsub node) - @param request(server.Request): current HTTP request - @param cache_type(int): on of C.CACHE_* const. - @param **kwargs: args according to cache_type: - C.CACHE_PUBSUB: - service: pubsub service - node: pubsub node - short: short name of feature (needed if node is empty to find namespace) - - """ - if request.postpath: - # we are not on the final page, no need to go further - return - - if request.uri != request.path: - # we don't cache page with query arguments as there can be a lot of variants - # influencing page results (e.g. search terms) - log.debug("ignoring cache due to query arguments") - - no_cache = not self.use_cache(request) - - profile = self.get_profile(request) or C.SERVICE_PROFILE - - if cache_type == C.CACHE_PUBSUB: - service, node = kwargs["service"], kwargs["node"] - if not node: - try: - short = kwargs["short"] - node = self.host.ns_map[short] - except KeyError: - log.warning(_('Can\'t use cache for empty node without namespace ' - 'set, please ensure to set "short" and that it is ' - 'registered')) - return - if profile != C.SERVICE_PROFILE: - # only service profile is cached for now - return - session_data = self.host.get_session_data(request, session_iface.IWebSession) - locale = session_data.locale - if locale == C.DEFAULT_LOCALE: - # no need to duplicate cache here - locale = None - try: - cache = (self.cache[profile][cache_type][service][node] - [self.vhost_root][request.uri][locale][self]) - except KeyError: - # no cache yet, let's subscribe to the pubsub node - d1 = self.host.bridge_call( - "ps_subscribe", service.full(), node, "", profile - ) - d1.addCallback(self.check_cache_subscribe_cb, service, node) - d1.addErrback(self.check_cache_subscribe_eb, service, node) - d2 = self.host.bridge_call("ps_node_watch_add", service.full(), node, profile) - d2.addErrback(self.ps_node_watch_add_eb, service, node) - self._do_cache = [self, profile, cache_type, service, node, - self.vhost_root, request.uri, locale] - # we don't return the Deferreds as it is not needed to wait for - # the subscription to continue with page rendering - return - else: - if no_cache: - del (self.cache[profile][cache_type][service][node] - [self.vhost_root][request.uri][locale][self]) - log.debug(f"cache removed for {self}") - return - - else: - raise exceptions.InternalError("Unknown cache_type") - log.debug("using cache for {page}".format(page=self)) - cache.last_access = time.time() - self._set_cache_headers(request, cache) - self._check_cache_headers(request, cache) - request.write(cache.rendered) - request.finish() - raise failure.Failure(exceptions.CancelError("cache is used")) - - def _cache_url(self, request, profile): - self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request) - - @classmethod - def on_node_event(cls, host, service, node, event_type, items, profile): - """Invalidate cache for all pages linked to this node""" - try: - cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node] - except KeyError: - log.info(_( - "Removing subscription for {service}/{node}: " - "the page is not cached").format(service=service, node=node)) - d1 = host.bridge_call("ps_unsubscribe", service, node, profile) - d1.addErrback( - lambda failure_: log.warning( - _("Can't unsubscribe from {service}/{node}: {msg}").format( - service=service, node=node, msg=failure_))) - d2 = host.bridge_call("ps_node_watch_add", service, node, profile) - # TODO: check why the page is not in cache, remove subscription? - d2.addErrback( - lambda failure_: log.warning( - _("Can't remove watch for {service}/{node}: {msg}").format( - service=service, node=node, msg=failure_))) - else: - cache.clear() - - # identities - - async def fill_missing_identities( - self, - request: server.Request, - entities: List[Union[str, jid.JID, None]], - ) -> None: - """Check if all entities have an identity cache, get missing ones from backend - - @param request: request with a plugged profile - @param entities: entities to check, None or empty strings will be filtered - """ - entities = {str(e) for e in entities if e} - profile = self.get_profile(request) or C.SERVICE_PROFILE - identities = self.host.get_session_data( - request, - session_iface.IWebSession - ).identities - for e in entities: - if e not in identities: - id_raw = await self.host.bridge_call( - 'identity_get', e, [], True, profile) - identities[e] = data_format.deserialise(id_raw) - - # signals, server => browser communication - - def delegate_to_resource(self, request, resource): - """continue workflow with Twisted Resource""" - buf = resource.render(request) - if buf == server.NOT_DONE_YET: - pass - else: - request.write(buf) - request.finish() - raise failure.Failure(exceptions.CancelError("resource delegation")) - - def http_redirect(self, request, url): - """redirect to an URL using HTTP redirection - - @param request(server.Request): current HTTP request - @param url(unicode): url to redirect to - """ - web_util.redirectTo(url.encode("utf-8"), request) - request.finish() - raise failure.Failure(exceptions.CancelError("HTTP redirection is used")) - - def redirect_or_continue(self, request, redirect_arg="redirect_url"): - """Helper method to redirect a page to an url given as arg - - if the arg is not present, the page will continue normal workflow - @param request(server.Request): current HTTP request - @param redirect_arg(unicode): argument to use to get redirection URL - @interrupt: redirect the page to requested URL - @interrupt page_error(C.HTTP_BAD_REQUEST): empty or non local URL is used - """ - redirect_arg = redirect_arg.encode('utf-8') - try: - url = request.args[redirect_arg][0].decode('utf-8') - except (KeyError, IndexError): - pass - else: - # a redirection is requested - if not url or url[0] != "/": - # we only want local urls - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - self.http_redirect(request, url) - - def page_redirect(self, page_path, request, skip_parse_url=True, path_args=None): - """redirect a page to a named page - - the workflow will continue with the workflow of the named page, - skipping named page's parse_url method if it exist. - If you want to do a HTTP redirection, use http_redirect - @param page_path(unicode): path to page (elements are separated by "/"): - if path starts with a "/": - path is a full path starting from root - else: - - first element is name as registered in name variable - - following element are subpages path - e.g.: "blog" redirect to page named "blog" - "blog/atom.xml" redirect to atom.xml subpage of "blog" - "/common/blog/atom.xml" redirect to the page at the given full path - @param request(server.Request): current HTTP request - @param skip_parse_url(bool): if True, parse_url method on redirect page will be - skipped - @param path_args(list[unicode], None): path arguments to use in redirected page - @raise KeyError: there is no known page with this name - """ - # FIXME: render non LiberviaPage resources - path = page_path.rstrip("/").split("/") - if not path[0]: - redirect_page = self.vhost_root - else: - redirect_page = self.named_pages[path[0]] - - for subpage in path[1:]: - subpage = subpage.encode('utf-8') - if redirect_page is self.vhost_root: - redirect_page = redirect_page.children[subpage] - else: - redirect_page = redirect_page.original.children[subpage] - - if path_args is not None: - args = [quote(a).encode() for a in path_args] - request.postpath = args + request.postpath - - if self._do_cache: - # if cache is needed, it will be handled by final page - redirect_page._do_cache = self._do_cache - self._do_cache = None - - defer.ensureDeferred( - redirect_page.render_page(request, skip_parse_url=skip_parse_url) - ) - raise failure.Failure(exceptions.CancelError("page redirection is used")) - - def page_error(self, request, code=C.HTTP_NOT_FOUND, no_body=False): - """generate an error page and terminate the request - - @param request(server.Request): HTTP request - @param core(int): error code to use - @param no_body: don't write body if True - """ - if self._do_cache is not None: - # we don't want to cache error pages - self._do_cache = None - request.setResponseCode(code) - if no_body: - request.finish() - else: - template = "error/" + str(code) + ".html" - template_data = request.template_data - session_data = self.host.get_session_data(request, session_iface.IWebSession) - if session_data.locale is not None: - template_data['locale'] = session_data.locale - if self.vhost_root.site_name: - template_data['site'] = self.vhost_root.site_name - - rendered = self.host.renderer.render( - template, - theme=session_data.theme or self.default_theme, - media_path=f"/{C.MEDIA_DIR}", - build_path=f"/{C.BUILD_DIR}/", - site_themes=self.site_themes, - error_code=code, - **template_data - ) - - self.write_data(rendered, request) - raise failure.Failure(exceptions.CancelError("error page is used")) - - def write_data(self, data, request): - """write data to transport and finish the request""" - if data is None: - self.page_error(request) - data_encoded = data.encode("utf-8") - - if self._do_cache is not None: - redirected_page = self._do_cache.pop(0) - cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache) - page_cache = cache[redirected_page] = CachePage(data_encoded) - self._set_cache_headers(request, page_cache) - log.debug(_("{page} put in cache for [{profile}]") - .format( page=self, profile=self._do_cache[0])) - self._do_cache = None - self._check_cache_headers(request, page_cache) - - try: - request.write(data_encoded) - except AttributeError: - log.warning(_("Can't write page, the request has probably been cancelled " - "(browser tab closed or reloaded)")) - return - request.finish() - - def _subpages_handler(self, request): - """render subpage if suitable - - this method checks if there is still an unmanaged part of the path - and check if it corresponds to a subpage. If so, it render the subpage - else it render a NoResource. - If there is no unmanaged part of the segment, current page workflow is pursued - """ - if request.postpath: - subpage = self.next_path(request).encode('utf-8') - try: - child = self.children[subpage] - except KeyError: - self.page_error(request) - else: - child.render(request) - raise failure.Failure(exceptions.CancelError("subpage page is used")) - - def _prepare_dynamic(self, request): - session_data = self.host.get_session_data(request, session_iface.IWebSession) - # we need to activate dynamic page - # we set data for template, and create/register token - # socket_token = str(uuid.uuid4()) - socket_url = self.host.get_websocket_url(request) - # as for CSRF, it is important to not let the socket token if we use the service - # profile, as those pages can be cached, and then the token leaked. - socket_token = '' if session_data.profile is None else session_data.ws_token - socket_debug = C.bool_const(self.host.debug) - request.template_data["websocket"] = WebsocketMeta( - socket_url, socket_token, socket_debug - ) - # we will keep track of handlers to remove - request._signals_registered = [] - # we will cache registered signals until socket is opened - request._signals_cache = [] - - def _render_template(self, request): - template_data = request.template_data - - # if confirm variable is set in case of successfuly data post - session_data = self.host.get_session_data(request, session_iface.IWebSession) - template_data['identities'] = session_data.identities - if session_data.pop_page_flag(self, C.FLAG_CONFIRM): - template_data["confirm"] = True - notifs = session_data.pop_page_notifications(self) - if notifs: - template_data["notifications"] = notifs - if session_data.jid is not None: - template_data["own_jid"] = session_data.jid - if session_data.locale is not None: - template_data['locale'] = session_data.locale - if session_data.guest: - template_data['guest_session'] = True - if self.vhost_root.site_name: - template_data['site'] = self.vhost_root.site_name - if self.dyn_data: - for data in self.dyn_data.values(): - try: - scripts = data['scripts'] - except KeyError: - pass - else: - template_data.setdefault('scripts', utils.OrderedSet()).update(scripts) - template_data.update(data.get('template', {})) - data_common = self.vhost_root.dyn_data_common - common_scripts = data_common['scripts'] - if common_scripts: - template_data.setdefault('scripts', utils.OrderedSet()).update(common_scripts) - if "template" in data_common: - for key, value in data_common["template"].items(): - if key not in template_data: - template_data[key] = value - - theme = session_data.theme or self.default_theme - self.expose_to_scripts( - request, - cache_path=session_data.cache_dir, - templates_root_url=str(self.vhost_root.get_front_url(theme)), - profile=session_data.profile) - - uri = request.uri.decode() - try: - template_data["current_page"] = next( - m[0] for m in self.main_menu if uri.startswith(m[1]) - ) - except StopIteration: - pass - - return self.host.renderer.render( - self.template, - theme=theme, - site_themes=self.site_themes, - page_url=self.get_url(), - media_path=f"/{C.MEDIA_DIR}", - build_path=f"/{C.BUILD_DIR}/", - cache_path=session_data.cache_dir, - main_menu=self.main_menu, - **template_data) - - def _on_data_post_redirect(self, ret, request): - """called when page's on_data_post has been done successfuly - - This will do a Post/Redirect/Get pattern. - this method redirect to the same page or to request.data['post_redirect_page'] - post_redirect_page can be either a page or a tuple with page as first item, then - a list of unicode arguments to append to the url. - if post_redirect_page is not used, initial request.uri (i.e. the same page as - where the data have been posted) will be used for redirection. - HTTP status code "See Other" (303) is used as it is the recommanded code in - this case. - @param ret(None, unicode, iterable): on_data_post return value - see LiberviaPage.__init__ on_data_post docstring - """ - if ret is None: - ret = () - elif isinstance(ret, str): - ret = (ret,) - else: - ret = tuple(ret) - raise NotImplementedError( - _("iterable in on_data_post return value is not used yet") - ) - session_data = self.host.get_session_data(request, session_iface.IWebSession) - request_data = self.get_r_data(request) - if "post_redirect_page" in request_data: - redirect_page_data = request_data["post_redirect_page"] - if isinstance(redirect_page_data, tuple): - redirect_page = redirect_page_data[0] - redirect_page_args = redirect_page_data[1:] - redirect_uri = redirect_page.get_url(*redirect_page_args) - else: - redirect_page = redirect_page_data - redirect_uri = redirect_page.url - else: - redirect_page = self - redirect_uri = request.uri - - if not C.POST_NO_CONFIRM in ret: - session_data.set_page_flag(redirect_page, C.FLAG_CONFIRM) - request.setResponseCode(C.HTTP_SEE_OTHER) - request.setHeader(b"location", redirect_uri) - request.finish() - raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used")) - - async def _on_data_post(self, request): - self.check_csrf(request) - try: - ret = await as_deferred(self.on_data_post, self, request) - except exceptions.DataError as e: - # something is wrong with the posted data, we re-display the page with a - # warning notification - session_data = self.host.get_session_data(request, session_iface.IWebSession) - session_data.set_page_notification(self, str(e), C.LVL_WARNING) - request.setResponseCode(C.HTTP_SEE_OTHER) - request.setHeader("location", request.uri) - request.finish() - raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used")) - else: - if ret != "continue": - self._on_data_post_redirect(ret, request) - - def get_posted_data( - self, - request: server.Request, - keys, - multiple: bool = False, - raise_on_missing: bool = True, - strip: bool = True - ): - """Get data from a POST request or from URL's query part and decode it - - @param request: request linked to the session - @param keys(unicode, iterable[unicode]): name of the value(s) to get - unicode to get one value - iterable to get more than one - @param multiple: True if multiple values are possible/expected - if False, the first value is returned - @param raise_on_missing: raise KeyError on missing key if True - else use None for missing values - @param strip: if True, apply "strip()" on values - @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): - values received for this(these) key(s) - @raise KeyError: one specific key has been requested, and it is missing - """ - # FIXME: request.args is already unquoting the value, it seems we are doing - # double unquote - if isinstance(keys, str): - keys = [keys] - - keys = [k.encode('utf-8') for k in keys] - - ret = [] - for key in keys: - gen = (urllib.parse.unquote(v.decode("utf-8")) - for v in request.args.get(key, [])) - if multiple: - ret.append(gen.strip() if strip else gen) - else: - try: - v = next(gen) - except StopIteration: - if raise_on_missing: - raise KeyError(key) - else: - ret.append(None) - else: - ret.append(v.strip() if strip else v) - - if len(keys) == 1: - return ret[0] - else: - return ret - - def get_all_posted_data(self, request, except_=(), multiple=True): - """get all posted data - - @param request(server.Request): request linked to the session - @param except_(iterable[unicode]): key of values to ignore - csrf_token will always be ignored - @param multiple(bool): if False, only the first values are returned - @return (dict[unicode, list[unicode]]): post values - """ - except_ = tuple(except_) + ("csrf_token",) - ret = {} - for key, values in request.args.items(): - key = key.decode('utf-8') - key = urllib.parse.unquote(key) - if key in except_: - continue - values = [v.decode('utf-8') for v in values] - if not multiple: - ret[key] = urllib.parse.unquote(values[0]) - else: - ret[key] = [urllib.parse.unquote(v) for v in values] - return ret - - def get_profile(self, request): - """Helper method to easily get current profile - - @return (unicode, None): current profile - None if no profile session is started - """ - web_session = self.host.get_session_data(request, session_iface.IWebSession) - return web_session.profile - - def get_jid(self, request): - """Helper method to easily get current jid - - @return: current jid - """ - web_session = self.host.get_session_data(request, session_iface.IWebSession) - return web_session.jid - - - def get_r_data(self, request): - """Helper method to get request data dict - - this dictionnary if for the request only, it is not saved in session - It is mainly used to pass data between pages/methods called during request - workflow - @return (dict): request data - """ - try: - return request.data - except AttributeError: - request.data = {} - return request.data - - def get_page_data(self, request, key): - """Helper method to retrieve reload resistant data""" - web_session = self.host.get_session_data(request, session_iface.IWebSession) - return web_session.get_page_data(self, key) - - def set_page_data(self, request, key, value): - """Helper method to set reload resistant data""" - web_session = self.host.get_session_data(request, session_iface.IWebSession) - return web_session.set_page_data(self, key, value) - - def handle_search(self, request, extra): - """Manage Full-Text Search - - Check if "search" query argument is present, and add MAM filter for it if - necessary. - If used, the "search" variable will also be available in template data, thus - frontend can display some information about it. - """ - search = self.get_posted_data(request, 'search', raise_on_missing=False) - if search is not None: - search = search.strip() - if search: - try: - extra[f'mam_filter_{self.host.ns_map["fulltextmam"]}'] = search - except KeyError: - log.warning(_("Full-text search is not available")) - else: - request.template_data['search'] = search - - def _check_access(self, request): - """Check access according to self.access - - if access is not granted, show a HTTP_FORBIDDEN page_error and stop request, - else return data (so it can be inserted in deferred chain - """ - if self.access == C.PAGES_ACCESS_PUBLIC: - pass - elif self.access == C.PAGES_ACCESS_PROFILE: - profile = self.get_profile(request) - if not profile: - # registration allowed, we redirect to login page - login_url = self.get_page_redirect_url(request) - self.http_redirect(request, login_url) - - def set_best_locale(self, request): - """Guess the best locale when it is not specified explicitly by user - - This method will check "accept-language" header, and set locale to first - matching value with available translations. - """ - accept_language = request.getHeader("accept-language") - if not accept_language: - return - accepted = [a.strip() for a in accept_language.split(',')] - available = [str(l) for l in self.host.renderer.translations] - for lang in accepted: - lang = lang.split(';')[0].strip().lower() - if not lang: - continue - for a in available: - if a.lower().startswith(lang): - session_data = self.host.get_session_data(request, - session_iface.IWebSession) - session_data.locale = a - return - - async def render_page(self, request, skip_parse_url=False): - """Main method to handle the workflow of a LiberviaPage""" - # template_data are the variables passed to template - if not hasattr(request, "template_data"): - # if template_data doesn't exist, it's the beginning of the request workflow - # so we fill essential data - session_data = self.host.get_session_data(request, session_iface.IWebSession) - profile = session_data.profile - request.template_data = { - "profile": profile, - # it's important to not add CSRF token and session uuid if service profile - # is used because the page may be cached, and the token then leaked - "csrf_token": "" if profile is None else session_data.csrf_token, - "session_uuid": "public" if profile is None else session_data.uuid, - "breadcrumbs": [] - } - - # XXX: here is the code which need to be executed once - # at the beginning of the request hanling - if request.postpath and not request.postpath[-1]: - # we don't differenciate URLs finishing with '/' or not - del request.postpath[-1] - - # i18n - key_lang = C.KEY_LANG.encode() - if key_lang in request.args: - try: - locale = request.args.pop(key_lang)[0].decode() - except IndexError: - log.warning("empty lang received") - else: - if "/" in locale: - # "/" is refused because locale may sometime be used to access - # path, if localised documents are available for instance - log.warning(_('illegal char found in locale ("/"), hack ' - 'attempt? locale={locale}').format(locale=locale)) - locale = None - session_data.locale = locale - - # if locale is not specified, we try to find one requested by browser - if session_data.locale is None: - self.set_best_locale(request) - - # theme - key_theme = C.KEY_THEME.encode() - if key_theme in request.args: - theme = request.args.pop(key_theme)[0].decode() - if key_theme != session_data.theme: - if theme not in self.site_themes: - log.warning(_( - "Theme {theme!r} doesn't exist for {vhost}" - .format(theme=theme, vhost=self.vhost_root))) - else: - session_data.theme = theme - try: - - try: - self._check_access(request) - - if self.redirect is not None: - self.page_redirect(self.redirect, request, skip_parse_url=False) - - if self.parse_url is not None and not skip_parse_url: - if self.url_cache: - profile = self.get_profile(request) - try: - cache_url = self.cached_urls[profile][request.uri] - except KeyError: - # no cache for this URI yet - # we do normal URL parsing, and then the cache - await as_deferred(self.parse_url, self, request) - self._cache_url(request, profile) - else: - log.debug(f"using URI cache for {self}") - cache_url.use(request) - else: - await as_deferred(self.parse_url, self, request) - - if self.add_breadcrumb is None: - label = ( - self.label - or self.name - or self.url[self.url.rfind('/')+1:] - ) - breadcrumb = { - "url": self.url, - "label": label.title(), - } - request.template_data["breadcrumbs"].append(breadcrumb) - else: - await as_deferred( - self.add_breadcrumb, - self, - request, - request.template_data["breadcrumbs"] - ) - - self._subpages_handler(request) - - if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST): - # only HTTP GET and POST are handled so far - self.page_error(request, C.HTTP_BAD_REQUEST) - - if request.method == C.HTTP_METHOD_POST: - if self.on_data_post == 'continue': - pass - elif self.on_data_post is None: - # if we don't have on_data_post, the page was not expecting POST - # so we return an error - self.page_error(request, C.HTTP_BAD_REQUEST) - else: - await self._on_data_post(request) - # by default, POST follow normal behaviour after on_data_post is called - # this can be changed by a redirection or other method call in on_data_post - - if self.dynamic: - self._prepare_dynamic(request) - - if self.prepare_render: - await as_deferred(self.prepare_render, self, request) - - if self.template: - rendered = self._render_template(request) - elif self.render_method: - rendered = await as_deferred(self.render_method, self, request) - else: - raise exceptions.InternalError( - "No method set to render page, please set a template or use a " - "render method" - ) - - self.write_data(rendered, request) - - except failure.Failure as f: - # we have to unpack the Failure to catch the right Exception - raise f.value - - except exceptions.CancelError: - pass - except BridgeException as e: - if e.condition == 'not-allowed': - log.warning("not allowed exception catched") - self.page_error(request, C.HTTP_FORBIDDEN) - elif e.condition == 'item-not-found' or e.classname == 'NotFound': - self.page_error(request, C.HTTP_NOT_FOUND) - elif e.condition == 'remote-server-not-found': - self.page_error(request, C.HTTP_NOT_FOUND) - elif e.condition == 'forbidden': - if self.get_profile(request) is None: - log.debug("access forbidden, we're redirecting to log-in page") - self.http_redirect(request, self.get_page_redirect_url(request)) - else: - self.page_error(request, C.HTTP_FORBIDDEN) - else: - log.error( - _("Uncatched bridge exception for HTTP request on {url}: {e}\n" - "page name: {name}\npath: {path}\nURL: {full_url}\n{tb}") - .format( - url=self.url, - e=e, - name=self.name or "", - path=self.root_dir, - full_url=request.URLPath(), - tb=traceback.format_exc(), - ) - ) - try: - self.page_error(request, C.HTTP_INTERNAL_ERROR) - except exceptions.CancelError: - pass - except Exception as e: - log.error( - _("Uncatched error for HTTP request on {url}: {e}\npage name: " - "{name}\npath: {path}\nURL: {full_url}\n{tb}") - .format( - url=self.url, - e=e, - name=self.name or "", - path=self.root_dir, - full_url=request.URLPath(), - tb=traceback.format_exc(), - ) - ) - try: - self.page_error(request, C.HTTP_INTERNAL_ERROR) - except exceptions.CancelError: - pass - - def render_GET(self, request): - defer.ensureDeferred(self.render_page(request)) - return server.NOT_DONE_YET - - def render_POST(self, request): - defer.ensureDeferred(self.render_page(request)) - return server.NOT_DONE_YET
--- a/libervia/server/pages_tools.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia Web frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -"""Helper methods for common operations on pages""" - -from twisted.internet import defer -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import data_format -from libervia.server.constants import Const as C - - -log = getLogger(__name__) - - -def deserialise(comments_data_s): - return data_format.deserialise(comments_data_s) - - -def retrieve_comments(self, service, node, profile, pass_exceptions=True): - """Retrieve comments from server and convert them to data objects - - @param service(unicode): service holding the comments - @param node(unicode): node to retrieve - @param profile(unicode): profile of the user willing to find comments - @param pass_exceptions(bool): if True bridge exceptions will be ignored but logged - else exception will be raised - """ - try: - d = self.host.bridge_call( - "mb_get", service, node, C.NO_LIMIT, [], data_format.serialise({}), profile - ) - except Exception as e: - if not pass_exceptions: - raise e - else: - log.warning( - _("Can't get comments at {node} (service: {service}): {msg}").format( - service=service, node=node, msg=e - ) - ) - return defer.succeed([]) - - d.addCallback(deserialise) - return d
--- a/libervia/server/proxy.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -from twisted.web import proxy -from twisted.python.compat import urlquote -from twisted.internet import address -from sat.core.log import getLogger -from libervia.server.constants import Const as C - -log = getLogger(__name__) - - - -class SatProxyClient(proxy.ProxyClient): - - def handleHeader(self, key, value): - if key.lower() == b"x-frame-options": - value = b"sameorigin" - elif key.lower() == b"content-security-policy": - value = value.replace(b"frame-ancestors 'none'", b"frame-ancestors 'self'") - - super().handleHeader(key, value) - - -class SatProxyClientFactory(proxy.ProxyClientFactory): - protocol = SatProxyClient - - -class SatReverseProxyResource(proxy.ReverseProxyResource): - """Resource Proxy rewritting headers to allow embedding in iframe on same domain""" - proxyClientFactoryClass = SatProxyClientFactory - - def getChild(self, path, request): - return SatReverseProxyResource( - self.host, self.port, - self.path + b'/' + urlquote(path, safe=b"").encode('utf-8'), - self.reactor - ) - - def render(self, request): - # Forwarded and X-Forwarded-xxx headers can be set - # if we have behind an other proxy - if ((not request.getHeader(C.H_FORWARDED) - and not request.getHeader(C.H_X_FORWARDED_HOST))): - forwarded_data = [] - addr = request.getClientAddress() - if ((isinstance(addr, address.IPv4Address) - or isinstance(addr, address.IPv6Address))): - request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_FOR, [addr.host]) - forwarded_data.append(f"for={addr.host}") - host = request.getHeader("host") - if host is None: - port = request.getHost().port - hostname = request.getRequestHostname() - host = hostname if port in (80, 443) else f"{hostname}:{port}" - request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_HOST, [host]) - forwarded_data.append(f"host={host}") - proto = "https" if request.isSecure() else "http" - request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_PROTO, [proto]) - forwarded_data.append(f"proto={proto}") - request.requestHeaders.setRawHeaders( - C.H_FORWARDED, [";".join(forwarded_data)] - ) - - return super().render(request)
--- a/libervia/server/resources.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,708 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia Web -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -import os.path -from pathlib import Path -import urllib.error -import urllib.parse -import urllib.request - -from twisted.internet import defer -from twisted.web import server -from twisted.web import static -from twisted.web import resource as web_resource - -from libervia.server.constants import Const as C -from libervia.server.utils import quote -from sat.core import exceptions -from sat.core.i18n import D_, _ -from sat.core.log import getLogger -from sat.tools.common import uri as common_uri -from sat.tools.common import data_format -from sat.tools.common.utils import OrderedSet, recursive_update - -from . import proxy - -log = getLogger(__name__) - - -class ProtectedFile(static.File): - """A static.File class which doesn't show directory listing""" - - def __init__(self, path, *args, **kwargs): - if "defaultType" not in kwargs and len(args) < 2: - # defaultType is second positional argument, and Twisted uses it - # in File.createSimilarFile, so we set kwargs only if it is missing - # in kwargs and it is not in a positional argument - kwargs["defaultType"] = "application/octet-stream" - super(ProtectedFile, self).__init__(str(path), *args, **kwargs) - - def directoryListing(self): - return web_resource.NoResource() - - - def getChild(self, path, request): - return super().getChild(path, request) - - def getChildWithDefault(self, path, request): - return super().getChildWithDefault(path, request) - - def getChildForRequest(self, request): - return super().getChildForRequest(request) - - -class LiberviaRootResource(ProtectedFile): - """Specialized resource for Libervia root - - handle redirections declared in sat.conf - """ - - def __init__(self, host, host_name, site_name, site_path, *args, **kwargs): - ProtectedFile.__init__(self, *args, **kwargs) - self.host = host - self.host_name = host_name - self.site_name = site_name - self.site_path = Path(site_path) - self.default_theme = self.config_get('theme') - if self.default_theme is None: - if not host_name: - # FIXME: we use bulma theme by default for main site for now - # as the development is focusing on this one, and default theme may - # be broken - self.default_theme = 'bulma' - else: - self.default_theme = C.TEMPLATE_THEME_DEFAULT - self.site_themes = set() - self.named_pages = {} - self.browser_modules = {} - # template dynamic data used in all pages - self.dyn_data_common = {"scripts": OrderedSet()} - for theme, data in host.renderer.get_themes_data(site_name).items(): - # we check themes for browser metadata, and merge them here if found - self.site_themes.add(theme) - browser_meta = data.get('browser_meta') - if browser_meta is not None: - log.debug(f"merging browser metadata from theme {theme}: {browser_meta}") - recursive_update(self.browser_modules, browser_meta) - browser_path = data.get('browser_path') - if browser_path is not None: - self.browser_modules.setdefault('themes_browser_paths', set()).add( - browser_path) - try: - next(browser_path.glob("*.py")) - except StopIteration: - pass - else: - log.debug(f"found brython script(s) for theme {theme}") - self.browser_modules.setdefault('brython', []).append( - { - "path": browser_path, - "url_hash": None, - "url_prefix": f"__t_{theme}" - } - ) - - self.uri_callbacks = {} - self.pages_redirects = {} - self.cached_urls = {} - self.main_menu = None - # map Libervia application names => data - self.libervia_apps = {} - self.build_path = host.get_build_path(site_name) - self.build_path.mkdir(parents=True, exist_ok=True) - self.dev_build_path = host.get_build_path(site_name, dev=True) - self.dev_build_path.mkdir(parents=True, exist_ok=True) - self.putChild( - C.BUILD_DIR.encode(), - ProtectedFile( - self.build_path, - defaultType="application/octet-stream"), - ) - - def __str__(self): - return ( - f"Root resource for {self.host_name or 'default host'} using " - f"{self.site_name or 'default site'} at {self.site_path} and deserving " - f"files at {self.path}" - ) - - def config_get(self, key, default=None, value_type=None): - """Retrieve configuration for this site - - params are the same as for [Libervia.config_get] - """ - return self.host.config_get(self, key, default, value_type) - - def get_front_url(self, theme): - return Path( - '/', - C.TPL_RESOURCE, - self.site_name or C.SITE_NAME_DEFAULT, - C.TEMPLATE_TPL_DIR, - theme) - - def add_resource_to_path(self, path: str, resource: web_resource.Resource) -> None: - """Add a resource to the given path - - A "NoResource" will be used for all intermediate segments - """ - segments, __, last_segment = path.rpartition("/") - url_segments = segments.split("/") if segments else [] - current = self - for segment in url_segments: - resource = web_resource.NoResource() - current.putChild(segment, resource) - current = resource - - current.putChild( - last_segment.encode('utf-8'), - resource - ) - - async def _start_app(self, app_name, extra=None) -> dict: - """Start a Libervia App - - @param app_name: canonical application name - @param extra: extra parameter to configure app - @return: app data - app data will not include computed exposed data, at this needs to wait for the - app to be started - """ - if extra is None: - extra = {} - log.info(_( - "starting application {app_name}").format(app_name=app_name)) - app_data = data_format.deserialise( - await self.host.bridge_call( - "application_start", app_name, data_format.serialise(extra) - ) - ) - if app_data.get("started", False): - log.debug(f"application {app_name!r} is already started or starting") - # we do not await on purpose, the workflow should not be blocking at this - # point - defer.ensureDeferred(self._on_app_started(app_name, app_data["instance"])) - else: - self.host.apps_cb[app_data["instance"]] = self._on_app_started - return app_data - - async def _on_app_started( - self, - app_name: str, - instance_id: str - ) -> None: - exposed_data = self.libervia_apps[app_name] = data_format.deserialise( - await self.host.bridge_call("application_exposed_get", app_name, "", "") - ) - - try: - web_port = int(exposed_data['ports']['web'].split(':')[1]) - except (KeyError, ValueError): - log.warning(_( - "no web port found for application {app_name!r}, can't use it " - ).format(app_name=app_name)) - raise exceptions.DataError("no web port found") - - try: - url_prefix = exposed_data['url_prefix'].strip().rstrip('/') - except (KeyError, AttributeError) as e: - log.warning(_( - "no URL prefix specified for this application, we can't embed it: {msg}") - .format(msg=e)) - raise exceptions.DataError("no URL prefix") - - if not url_prefix.startswith('/'): - raise exceptions.DataError( - f"invalid URL prefix, it must start with '/': {url_prefix!r}") - - res = proxy.SatReverseProxyResource( - "localhost", - web_port, - url_prefix.encode() - ) - self.add_resource_to_path(url_prefix, res) - log.info( - f"Resource for app {app_name!r} (instance {instance_id!r}) has been added" - ) - - async def _init_redirections(self, options): - url_redirections = options["url_redirections_dict"] - - url_redirections = url_redirections.get(self.site_name, {}) - - ## redirections - self.redirections = {} - self.inv_redirections = {} # new URL to old URL map - - for old, new_data_list in url_redirections.items(): - # several redirections can be used for one path by using a list. - # The redirection will be done using first item of the list, and all items - # will be used for inverse redirection. - # e.g. if a => [b, c], a will redirect to c, and b and c will both be - # equivalent to a - if not isinstance(new_data_list, list): - new_data_list = [new_data_list] - for new_data in new_data_list: - # new_data can be a dictionary or a unicode url - if isinstance(new_data, dict): - # new_data dict must contain either "url", "page" or "path" key - # (exclusive) - # if "path" is used, a file url is constructed with it - if (( - len( - {"path", "url", "page"}.intersection(list(new_data.keys())) - ) != 1 - )): - raise ValueError( - 'You must have one and only one of "url", "page" or "path" ' - 'key in your url_redirections_dict data' - ) - if "url" in new_data: - new = new_data["url"] - elif "page" in new_data: - new = new_data - new["type"] = "page" - new.setdefault("path_args", []) - if not isinstance(new["path_args"], list): - log.error( - _('"path_args" in redirection of {old} must be a list. ' - 'Ignoring the redirection'.format(old=old))) - continue - new.setdefault("query_args", {}) - if not isinstance(new["query_args"], dict): - log.error( - _( - '"query_args" in redirection of {old} must be a ' - 'dictionary. Ignoring the redirection' - ).format(old=old) - ) - continue - new["path_args"] = [quote(a) for a in new["path_args"]] - # we keep an inversed dict of page redirection - # (page/path_args => redirecting URL) - # so get_url can return the redirecting URL if the same arguments - # are used # making the URL consistent - args_hash = tuple(new["path_args"]) - self.pages_redirects.setdefault(new_data["page"], {}).setdefault( - args_hash, - old - ) - - # we need lists in query_args because it will be used - # as it in request.path_args - for k, v in new["query_args"].items(): - if isinstance(v, str): - new["query_args"][k] = [v] - elif "path" in new_data: - new = "file:{}".format(urllib.parse.quote(new_data["path"])) - elif isinstance(new_data, str): - new = new_data - new_data = {} - else: - log.error( - _("ignoring invalid redirection value: {new_data}").format( - new_data=new_data - ) - ) - continue - - # some normalization - if not old.strip(): - # root URL special case - old = "" - elif not old.startswith("/"): - log.error( - _("redirected url must start with '/', got {value}. Ignoring") - .format(value=old) - ) - continue - else: - old = self._normalize_url(old) - - if isinstance(new, dict): - # dict are handled differently, they contain data - # which ared use dynamically when the request is done - self.redirections.setdefault(old, new) - if not old: - if new["type"] == "page": - log.info( - _("Root URL redirected to page {name}").format( - name=new["page"] - ) - ) - else: - if new["type"] == "page": - page = self.get_page_by_name(new["page"]) - url = page.get_url(*new.get("path_args", [])) - self.inv_redirections[url] = old - continue - - # at this point we have a redirection URL in new, we can parse it - new_url = urllib.parse.urlsplit(new) - - # we handle the known URL schemes - if new_url.scheme == "xmpp": - location = self.get_page_path_from_uri(new) - if location is None: - log.warning( - _("ignoring redirection, no page found to handle this URI: " - "{uri}").format(uri=new)) - continue - request_data = self._get_request_data(location) - self.inv_redirections[location] = old - - elif new_url.scheme in ("", "http", "https"): - # direct redirection - if new_url.netloc: - raise NotImplementedError( - "netloc ({netloc}) is not implemented yet for " - "url_redirections_dict, it is not possible to redirect to an " - "external website".format(netloc=new_url.netloc)) - location = urllib.parse.urlunsplit( - ("", "", new_url.path, new_url.query, new_url.fragment) - ) - request_data = self._get_request_data(location) - self.inv_redirections[location] = old - - elif new_url.scheme == "file": - # file or directory - if new_url.netloc: - raise NotImplementedError( - "netloc ({netloc}) is not implemented for url redirection to " - "file system, it is not possible to redirect to an external " - "host".format( - netloc=new_url.netloc)) - path = urllib.parse.unquote(new_url.path) - if not os.path.isabs(path): - raise ValueError( - "file redirection must have an absolute path: e.g. " - "file:/path/to/my/file") - # for file redirection, we directly put child here - resource_class = ( - ProtectedFile if new_data.get("protected", True) else static.File - ) - res = resource_class(path, defaultType="application/octet-stream") - self.add_resource_to_path(old, res) - log.info("[{host_name}] Added redirection from /{old} to file system " - "path {path}".format(host_name=self.host_name, - old=old, - path=path)) - - # we don't want to use redirection system, so we continue here - continue - - elif new_url.scheme == "libervia-app": - # a Libervia application - - app_name = urllib.parse.unquote(new_url.path).lower().strip() - extra = {"url_prefix": f"/{old}"} - try: - await self._start_app(app_name, extra) - except Exception as e: - log.warning(_( - "Can't launch {app_name!r} for path /{old}: {e}").format( - app_name=app_name, old=old, e=e)) - continue - - log.info( - f"[{self.host_name}] Added redirection from /{old} to " - f"application {app_name}" - ) - # normal redirection system is not used here - continue - elif new_url.scheme == "proxy": - # a reverse proxy - host, port = new_url.hostname, new_url.port - if host is None or port is None: - raise ValueError( - "invalid host or port in proxy redirection, please check your " - "configuration: {new_url.geturl()}" - ) - url_prefix = (new_url.path or old).rstrip('/') - res = proxy.SatReverseProxyResource( - host, - port, - url_prefix.encode(), - ) - self.add_resource_to_path(old, res) - log.info( - f"[{self.host_name}] Added redirection from /{old} to reverse proxy " - f"{new_url.netloc} with URL prefix {url_prefix}/" - ) - - # normal redirection system is not used here - continue - else: - raise NotImplementedError( - "{scheme}: scheme is not managed for url_redirections_dict".format( - scheme=new_url.scheme - ) - ) - - self.redirections.setdefault(old, request_data) - if not old: - log.info(_("[{host_name}] Root URL redirected to {uri}") - .format(host_name=self.host_name, - uri=request_data[1])) - - # the default root URL, if not redirected - if not "" in self.redirections: - self.redirections[""] = self._get_request_data(C.LIBERVIA_PAGE_START) - - async def _set_menu(self, menus): - menus = menus.get(self.site_name, []) - main_menu = [] - for menu in menus: - if not menu: - msg = _("menu item can't be empty") - log.error(msg) - raise ValueError(msg) - elif isinstance(menu, list): - if len(menu) != 2: - msg = _( - "menu item as list must be in the form [page_name, absolue URL]" - ) - log.error(msg) - raise ValueError(msg) - page_name, url = menu - elif menu.startswith("libervia-app:"): - app_name = menu[13:].strip().lower() - app_data = await self._start_app(app_name) - exposed_data = app_data["expose"] - front_url = exposed_data['front_url'] - options = self.host.options - url_redirections = options["url_redirections_dict"].setdefault( - self.site_name, {} - ) - if front_url in url_redirections: - raise exceptions.ConflictError( - f"There is already a redirection from {front_url!r}, can't add " - f"{app_name!r}") - - url_redirections[front_url] = { - "page": 'embed_app', - "path_args": [app_name] - } - - page_name = exposed_data.get('web_label', app_name).title() - url = front_url - - log.debug( - f"Application {app_name} added to menu of {self.site_name}" - ) - else: - page_name = menu - try: - url = self.get_page_by_name(page_name).url - except KeyError as e: - log_msg = _("Can'find a named page ({msg}), please check " - "menu_json in configuration.").format(msg=e.args[0]) - log.error(log_msg) - raise exceptions.ConfigError(log_msg) - main_menu.append((page_name, url)) - self.main_menu = main_menu - - def _normalize_url(self, url, lower=True): - """Return URL normalized for self.redirections dict - - @param url(unicode): URL to normalize - @param lower(bool): lower case of url if True - @return (str): normalized URL - """ - if lower: - url = url.lower() - return "/".join((p for p in url.split("/") if p)) - - def _get_request_data(self, uri): - """Return data needed to redirect request - - @param url(unicode): destination url - @return (tuple(list[str], str, str, dict): tuple with - splitted path as in Request.postpath - uri as in Request.uri - path as in Request.path - args as in Request.args - """ - uri = uri - # XXX: we reuse code from twisted.web.http.py here - # as we need to have the same behaviour - x = uri.split("?", 1) - - if len(x) == 1: - path = uri - args = {} - else: - path, argstring = x - args = urllib.parse.parse_qs(argstring, True) - - # XXX: splitted path case must not be changed, as it may be significant - # (e.g. for blog items) - return ( - self._normalize_url(path, lower=False).split("/"), - uri, - path, - args, - ) - - def _redirect(self, request, request_data): - """Redirect an URL by rewritting request - - this is *NOT* a HTTP redirection, but equivalent to URL rewritting - @param request(web.http.request): original request - @param request_data(tuple): data returned by self._get_request_data - @return (web_resource.Resource): resource to use - """ - # recursion check - try: - request._redirected - except AttributeError: - pass - else: - try: - __, uri, __, __ = request_data - except ValueError: - uri = "" - log.error(D_( "recursive redirection, please fix this URL:\n" - "{old} ==> {new}").format( - old=request.uri.decode("utf-8"), new=uri)) - return web_resource.NoResource() - - request._redirected = True # here to avoid recursive redirections - - if isinstance(request_data, dict): - if request_data["type"] == "page": - try: - page = self.get_page_by_name(request_data["page"]) - except KeyError: - log.error( - _( - 'Can\'t find page named "{name}" requested in redirection' - ).format(name=request_data["page"]) - ) - return web_resource.NoResource() - path_args = [pa.encode('utf-8') for pa in request_data["path_args"]] - request.postpath = path_args + request.postpath - - try: - request.args.update(request_data["query_args"]) - except (TypeError, ValueError): - log.error( - _("Invalid args in redirection: {query_args}").format( - query_args=request_data["query_args"] - ) - ) - return web_resource.NoResource() - return page - else: - raise exceptions.InternalError("unknown request_data type") - else: - path_list, uri, path, args = request_data - path_list = [p.encode('utf-8') for p in path_list] - log.debug( - "Redirecting URL {old} to {new}".format( - old=request.uri.decode('utf-8'), new=uri - ) - ) - # we change the request to reflect the new url - request.postpath = path_list[1:] + request.postpath - request.args.update(args) - - # we start again to look for a child with the new url - return self.getChildWithDefault(path_list[0], request) - - def get_page_by_name(self, name): - """Retrieve page instance from its name - - @param name(unicode): name of the page - @return (LiberviaPage): page instance - @raise KeyError: the page doesn't exist - """ - return self.named_pages[name] - - def get_page_path_from_uri(self, uri): - """Retrieve page URL from xmpp: URI - - @param uri(unicode): URI with a xmpp: scheme - @return (unicode,None): absolute path (starting from root "/") to page handling - the URI. - None is returned if no page has been registered for this URI - """ - uri_data = common_uri.parse_xmpp_uri(uri) - try: - page, cb = self.uri_callbacks[uri_data["type"], uri_data["sub_type"]] - except KeyError: - url = None - else: - url = cb(page, uri_data) - if url is None: - # no handler found - # we try to find a more generic one - try: - page, cb = self.uri_callbacks[uri_data["type"], None] - except KeyError: - pass - else: - url = cb(page, uri_data) - return url - - def getChildWithDefault(self, name, request): - # XXX: this method is overriden only for root url - # which is the only ones who need to be handled before other children - if name == b"" and not request.postpath: - return self._redirect(request, self.redirections[""]) - return super(LiberviaRootResource, self).getChildWithDefault(name, request) - - def getChild(self, name, request): - resource = super(LiberviaRootResource, self).getChild(name, request) - - if isinstance(resource, web_resource.NoResource): - # if nothing was found, we try our luck with redirections - # XXX: we want redirections to happen only if everything else failed - path_elt = request.prepath + request.postpath - for idx in range(len(path_elt), -1, -1): - test_url = b"/".join(path_elt[:idx]).decode('utf-8').lower() - if test_url in self.redirections: - request_data = self.redirections[test_url] - request.postpath = path_elt[idx:] - return self._redirect(request, request_data) - - return resource - - def putChild(self, path, resource): - """Add a child to the root resource""" - if not isinstance(path, bytes): - raise ValueError("path must be specified in bytes") - if not isinstance(resource, web_resource.EncodingResourceWrapper): - # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) - resource = web_resource.EncodingResourceWrapper( - resource, [server.GzipEncoderFactory()]) - - super(LiberviaRootResource, self).putChild(path, resource) - - def createSimilarFile(self, path): - # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource - - f = LiberviaRootResource.__base__( - path, self.defaultType, self.ignoredExts, self.registry - ) - # refactoring by steps, here - constructor should almost certainly take these - f.processors = self.processors - f.indexNames = self.indexNames[:] - f.childNotFound = self.childNotFound - return f
--- a/libervia/server/restricted_bridge.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a SàT frontend -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from sat.tools.common import data_format -from sat.core import exceptions -from libervia.server.constants import Const as C - - -class RestrictedBridge: - """bridge with limited access, which can be used in browser - - Only a few method are implemented, with potentially dangerous argument controlled. - Security limit is used - """ - - def __init__(self, host): - self.host = host - self.security_limit = C.SECURITY_LIMIT - - def no_service_profile(self, profile): - """Raise an error if service profile is used""" - if profile == C.SERVICE_PROFILE: - raise exceptions.PermissionError( - "This action is not allowed for service profile" - ) - - async def action_launch( - self, callback_id: str, data_s: str, profile: str - ) -> str: - self.no_service_profile(profile) - return await self.host.bridge_call( - "action_launch", callback_id, data_s, profile - ) - - async def call_start(self, entity: str, call_data_s: str, profile: str) -> None: - self.no_service_profile(profile) - return await self.host.bridge_call( - "call_start", entity, call_data_s, profile - ) - - async def call_end(self, session_id: str, call_data: str, profile: str) -> None: - self.no_service_profile(profile) - return await self.host.bridge_call( - "call_end", session_id, call_data, profile - ) - - async def contacts_get(self, profile): - return await self.host.bridge_call("contacts_get", profile) - - async def external_disco_get(self, entity, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "external_disco_get", entity, profile) - - async def ice_candidates_add(self, session_id, media_ice_data_s, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ice_candidates_add", session_id, media_ice_data_s, profile - ) - - async def identity_get(self, entity, metadata_filter, use_cache, profile): - return await self.host.bridge_call( - "identity_get", entity, metadata_filter, use_cache, profile) - - async def identities_get(self, entities, metadata_filter, profile): - return await self.host.bridge_call( - "identities_get", entities, metadata_filter, profile) - - async def identities_base_get(self, profile): - return await self.host.bridge_call( - "identities_base_get", profile) - - async def ps_node_delete(self, service_s, node, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_node_delete", service_s, node, profile) - - async def ps_node_affiliations_set(self, service_s, node, affiliations, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_node_affiliations_set", service_s, node, affiliations, profile) - - async def ps_item_retract(self, service_s, node, item_id, notify, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_item_retract", service_s, node, item_id, notify, profile) - - async def mb_preview(self, service_s, node, data, profile): - return await self.host.bridge_call( - "mb_preview", service_s, node, data, profile) - - async def list_set(self, service_s, node, values, schema, item_id, extra, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "list_set", service_s, node, values, "", item_id, "", profile) - - - async def file_http_upload_get_slot( - self, filename, size, content_type, upload_jid, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "file_http_upload_get_slot", filename, size, content_type, - upload_jid, profile) - - async def file_sharing_delete( - self, service_jid, path, namespace, profile): - self.no_service_profile(profile) - return await self.host.bridge_call( - "file_sharing_delete", service_jid, path, namespace, profile) - - async def interests_file_sharing_register( - self, service, repos_type, namespace, path, name, extra_s, profile - ): - self.no_service_profile(profile) - if extra_s: - # we only allow "thumb_url" here - extra = data_format.deserialise(extra_s) - if "thumb_url" in extra: - extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]}) - else: - extra_s = "" - - return await self.host.bridge_call( - "interests_file_sharing_register", service, repos_type, namespace, path, name, - extra_s, profile - ) - - async def interest_retract( - self, service_jid, item_id, profile - ): - self.no_service_profile(profile) - return await self.host.bridge_call( - "interest_retract", service_jid, item_id, profile) - - async def ps_invite( - self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile - ): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile - ) - - async def fis_invite( - self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s, - profile - ): - self.no_service_profile(profile) - if extra_s: - # we only allow "thumb_url" here - extra = data_format.deserialise(extra_s) - if "thumb_url" in extra: - extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]}) - else: - extra_s = "" - - return await self.host.bridge_call( - "fis_invite", invitee_jid_s, service_s, repos_type, namespace, path, name, - extra_s, profile - ) - - async def fis_affiliations_set( - self, service_s, namespace, path, affiliations, profile - ): - self.no_service_profile(profile) - return await self.host.bridge_call( - "fis_affiliations_set", service_s, namespace, path, affiliations, profile - ) - - async def invitation_simple_create( - self, invitee_email, invitee_name, url_template, extra_s, profile - ): - self.no_service_profile(profile) - return await self.host.bridge_call( - "invitation_simple_create", invitee_email, invitee_name, url_template, extra_s, - profile - )
--- a/libervia/server/server.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1374 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia Web -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -from functools import partial -import os.path -from pathlib import Path -import re -import sys -import time -from typing import Callable, Dict, Optional -import urllib.error -import urllib.parse -import urllib.request - -from twisted.application import service -from twisted.internet import defer, inotify, reactor -from twisted.python import failure -from twisted.python import filepath -from twisted.python.components import registerAdapter -from twisted.web import server -from twisted.web import static -from twisted.web import resource as web_resource -from twisted.web import util as web_util -from twisted.web import vhost -from twisted.words.protocols.jabber import jid - -import libervia -from libervia.server import websockets -from libervia.server import session_iface -from libervia.server.constants import Const as C -from libervia.server.pages import LiberviaPage -from libervia.server.tasks.manager import TasksManager -from libervia.server.utils import ProgressHandler -from sat.core import exceptions -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools import utils -from sat.tools import config -from sat.tools.common import regex -from sat.tools.common import template -from sat.tools.common import data_format -from sat.tools.common import tls -from sat_frontends.bridge.bridge_frontend import BridgeException -from sat_frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge -from sat_frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT - -from .resources import LiberviaRootResource, ProtectedFile -from .restricted_bridge import RestrictedBridge - -log = getLogger(__name__) - - -DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF - | inotify.IN_MOVED_TO) - - -class SysExit(Exception): - - def __init__(self, exit_code, message=""): - self.exit_code = exit_code - self.message = message - - def __str__(self): - return f"System Exit({self.exit_code}): {self.message}" - - -class FilesWatcher(object): - """Class to check files modifications using iNotify""" - _notifier = None - - def __init__(self, host): - self.host = host - - @property - def notifier(self): - if self._notifier == None: - notifier = self.__class__._notifier = inotify.INotify() - notifier.startReading() - return self._notifier - - def _check_callback(self, dir_path, callback, recursive): - # Twisted doesn't add callback if a watcher was already set on a path - # but in dev mode Libervia watches whole sites + internal path can be watched - # by tasks, so several callbacks must be called on some paths. - # This method check that the new callback is indeed present in the desired path - # and add it otherwise. - # FIXME: this should probably be fixed upstream - if recursive: - for child in dir_path.walk(): - if child.isdir(): - self._check_callback(child, callback, recursive=False) - else: - watch_id = self.notifier._isWatched(dir_path) - if watch_id is None: - log.warning( - f"There is no watch ID for path {dir_path}, this should not happen" - ) - else: - watch_point = self.notifier._watchpoints[watch_id] - if callback not in watch_point.callbacks: - watch_point.callbacks.append(callback) - - def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False, - recursive=False, **kwargs): - dir_path = str(dir_path) - log.info(_("Watching directory {dir_path}").format(dir_path=dir_path)) - wrapped_callback = lambda __, filepath, mask: callback( - self.host, filepath, inotify.humanReadableMask(mask), **kwargs) - callbacks = [wrapped_callback] - dir_path = filepath.FilePath(dir_path) - self.notifier.watch( - dir_path, mask=mask, autoAdd=auto_add, recursive=recursive, - callbacks=callbacks) - self._check_callback(dir_path, wrapped_callback, recursive) - - -class WebSession(server.Session): - sessionTimeout = C.SESSION_TIMEOUT - - def __init__(self, *args, **kwargs): - self.__lock = False - server.Session.__init__(self, *args, **kwargs) - - def lock(self): - """Prevent session from expiring""" - self.__lock = True - self._expireCall.reset(sys.maxsize) - - def unlock(self): - """Allow session to expire again, and touch it""" - self.__lock = False - self.touch() - - def touch(self): - if not self.__lock: - server.Session.touch(self) - - -class WaitingRequests(dict): - def set_request(self, request, profile, register_with_ext_jid=False): - """Add the given profile to the waiting list. - - @param request (server.Request): the connection request - @param profile (str): %(doc_profile)s - @param register_with_ext_jid (bool): True if we will try to register the - profile with an external XMPP account credentials - """ - dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile) - self[profile] = (request, dc, register_with_ext_jid) - - def purge_request(self, profile): - """Remove the given profile from the waiting list. - - @param profile (str): %(doc_profile)s - """ - try: - dc = self[profile][1] - except KeyError: - return - if dc.active(): - dc.cancel() - del self[profile] - - def get_request(self, profile): - """Get the waiting request for the given profile. - - @param profile (str): %(doc_profile)s - @return: the waiting request or None - """ - return self[profile][0] if profile in self else None - - def get_register_with_ext_jid(self, profile): - """Get the value of the register_with_ext_jid parameter. - - @param profile (str): %(doc_profile)s - @return: bool or None - """ - return self[profile][2] if profile in self else None - - -class Libervia(service.Service): - debug = defer.Deferred.debug # True if twistd/Libervia is launched in debug mode - - def __init__(self, options): - self.options = options - websockets.host = self - - def _init(self): - # we do init here and not in __init__ to avoid doule initialisation with twistd - # this _init is called in startService - self.initialised = defer.Deferred() - self.waiting_profiles = WaitingRequests() # FIXME: should be removed - self._main_conf = None - self.files_watcher = FilesWatcher(self) - - if self.options["base_url_ext"]: - self.base_url_ext = self.options.pop("base_url_ext") - if self.base_url_ext[-1] != "/": - self.base_url_ext += "/" - self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext) - else: - self.base_url_ext = None - # we split empty string anyway so we can do things like - # scheme = self.base_url_ext_data.scheme or 'https' - self.base_url_ext_data = urllib.parse.urlsplit("") - - if not self.options["port_https_ext"]: - self.options["port_https_ext"] = self.options["port_https"] - - self._cleanup = [] - - self.sessions = {} # key = session value = user - self.prof_connected = set() # Profiles connected - self.ns_map = {} # map of short name to namespaces - - ## bridge ## - self._bridge_retry = self.options['bridge-retries'] - self.bridge = bridge() - self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) - - ## libervia app callbacks ## - # mapping instance id to the callback to call on "started" signal - self.apps_cb: Dict[str, Callable] = {} - - @property - def roots(self): - """Return available virtual host roots - - Root resources are only returned once, even if they are present for multiple - named vhosts. Order is not relevant, except for default vhost which is always - returned first. - @return (list[web_resource.Resource]): all vhost root resources - """ - roots = list(set(self.vhost_root.hosts.values())) - default = self.vhost_root.default - if default is not None and default not in roots: - roots.insert(0, default) - return roots - - @property - def main_conf(self): - """SafeConfigParser instance opened on configuration file (sat.conf)""" - if self._main_conf is None: - self._main_conf = config.parse_main_conf(log_filenames=True) - return self._main_conf - - def config_get(self, site_root_res, key, default=None, value_type=None): - """Retrieve configuration associated to a site - - Section is automatically set to site name - @param site_root_res(LiberviaRootResource): resource of the site in use - @param key(unicode): key to use - @param default: value to use if not found (see [config.config_get]) - @param value_type(unicode, None): filter to use on value - Note that filters are already automatically used when the key finish - by a well known suffix ("_path", "_list", "_dict", or "_json") - None to use no filter, else can be: - - "path": a path is expected, will be normalized and expanded - - """ - section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION - value = config.config_get(self.main_conf, section, key, default=default) - if value_type is not None: - if value_type == 'path': - v_filter = lambda v: os.path.abspath(os.path.expanduser(v)) - else: - raise ValueError("unknown value type {value_type}".format( - value_type = value_type)) - if isinstance(value, list): - value = [v_filter(v) for v in value] - elif isinstance(value, dict): - value = {k:v_filter(v) for k,v in list(value.items())} - elif value is not None: - value = v_filter(value) - return value - - def _namespaces_get_cb(self, ns_map): - self.ns_map = {str(k): str(v) for k,v in ns_map.items()} - - def _namespaces_get_eb(self, failure_): - log.error(_("Can't get namespaces map: {msg}").format(msg=failure_)) - - @template.contextfilter - def _front_url_filter(self, ctx, relative_url): - template_data = ctx['template_data'] - return os.path.join( - '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT, - C.TEMPLATE_TPL_DIR, template_data.theme, relative_url) - - def _move_first_level_to_dict(self, options, key, keys_to_keep): - """Read a config option and put value at first level into u'' dict - - This is useful to put values for Libervia official site directly in dictionary, - and to use site_name as keys when external sites are used. - options will be modified in place - @param options(dict): options to modify - @param key(unicode): setting key to modify - @param keys_to_keep(list(unicode)): keys allowed in first level - """ - try: - conf = options[key] - except KeyError: - return - if not isinstance(conf, dict): - options[key] = {'': conf} - return - default_dict = conf.get('', {}) - to_delete = [] - for key, value in conf.items(): - if key not in keys_to_keep: - default_dict[key] = value - to_delete.append(key) - for key in to_delete: - del conf[key] - if default_dict: - conf[''] = default_dict - - async def check_and_connect_service_profile(self): - passphrase = self.options["passphrase"] - if not passphrase: - raise SysExit( - C.EXIT_BAD_ARG, - _("No passphrase set for service profile, please check installation " - "documentation.") - ) - try: - s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE) - except BridgeException as e: - if e.classname == "ProfileUnknownError": - log.info("Service profile doesn't exist, creating it.") - try: - xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain") - xmpp_domain = xmpp_domain.strip() - if not xmpp_domain: - raise SysExit( - C.EXIT_BAD_ARG, - _('"xmpp_domain" must be set to create new accounts, please ' - 'check documentation') - ) - service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}" - await self.bridge_call( - "in_band_account_new", - service_profile_jid_s, - passphrase, - "", - xmpp_domain, - 0, - ) - except BridgeException as e: - if e.condition == "conflict": - log.info( - _("Service's profile JID {profile_jid} already exists") - .format(profile_jid=service_profile_jid_s) - ) - elif e.classname == "UnknownMethod": - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Can't create service profile XMPP account, In-Band " - "Registration plugin is not activated, you'll have to " - "create the {profile!r} profile with {profile_jid!r} JID " - "manually.").format( - profile=C.SERVICE_PROFILE, - profile_jid=service_profile_jid_s) - ) - elif e.condition == "service-unavailable": - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Can't create service profile XMPP account, In-Band " - "Registration is not activated on your server, you'll have " - "to create the {profile!r} profile with {profile_jid!r} JID " - "manually.\nNote that you'll need to activate In-Band " - "Registation on your server if you want users to be able " - "to create new account from {app_name}, please check " - "documentation.").format( - profile=C.SERVICE_PROFILE, - profile_jid=service_profile_jid_s, - app_name=C.APP_NAME) - ) - elif e.condition == "not-acceptable": - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Can't create service profile XMPP account, your XMPP " - "server doesn't allow us to create new accounts with " - "In-Band Registration please check XMPP server " - "configuration: {reason}" - ).format( - profile=C.SERVICE_PROFILE, - profile_jid=service_profile_jid_s, - reason=e.message) - ) - - else: - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Can't create service profile XMPP account, you'll have " - "do to it manually: {reason}").format(reason=e.message) - ) - try: - await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase) - await self.bridge_call( - "profile_start_session", passphrase, C.SERVICE_PROFILE) - await self.bridge_call( - "param_set", "JabberID", service_profile_jid_s, "Connection", -1, - C.SERVICE_PROFILE) - await self.bridge_call( - "param_set", "Password", passphrase, "Connection", -1, - C.SERVICE_PROFILE) - except BridgeException as e: - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Can't create service profile XMPP account, you'll have " - "do to it manually: {reason}").format(reason=e.message) - ) - log.info(_("Service profile has been successfully created")) - s_prof_connected = False - else: - raise SysExit(C.EXIT_BRIDGE_ERROR, e.message) - - if not s_prof_connected: - try: - await self.bridge_call( - "connect", - C.SERVICE_PROFILE, - passphrase, - {}, - ) - except BridgeException as e: - raise SysExit( - C.EXIT_BRIDGE_ERROR, - _("Connection of service profile failed: {reason}").format(reason=e) - ) - - async def backend_ready(self): - log.info(f"Libervia Web v{self.full_version}") - - # settings - if self.options['dev-mode']: - log.info(_("Developer mode activated")) - self.media_dir = await self.bridge_call("config_get", "", "media_dir") - self.local_dir = await self.bridge_call("config_get", "", "local_dir") - self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR) - self.renderer = template.Renderer(self, self._front_url_filter) - sites_names = list(self.renderer.sites_paths.keys()) - - self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names) - self._move_first_level_to_dict(self.options, "menu_json", sites_names) - self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names) - menu = self.options["menu_json"] - if not '' in menu: - menu[''] = C.DEFAULT_MENU - for site, value in self.options["menu_extra_json"].items(): - menu[site].extend(value) - - # service profile - if not self.options['build-only']: - await self.check_and_connect_service_profile() - - # restricted bridge, the one used by browser code - self.restricted_bridge = RestrictedBridge(self) - - # we create virtual hosts and import Libervia pages into them - self.vhost_root = vhost.NameVirtualHost() - default_site_path = Path(libervia.__file__).parent.resolve() - # self.sat_root is official Libervia site - root_path = default_site_path / C.TEMPLATE_STATIC_DIR - self.sat_root = default_root = LiberviaRootResource( - host=self, host_name='', site_name='', - site_path=default_site_path, path=root_path) - if self.options['dev-mode']: - self.files_watcher.watch_dir( - default_site_path, auto_add=True, recursive=True, - callback=LiberviaPage.on_file_change, site_root=self.sat_root, - site_path=default_site_path) - LiberviaPage.import_pages(self, self.sat_root) - tasks_manager = TasksManager(self, self.sat_root) - await tasks_manager.parse_tasks() - await tasks_manager.run_tasks() - # FIXME: handle _set_menu in a more generic way, taking care of external sites - await self.sat_root._set_menu(self.options["menu_json"]) - self.vhost_root.default = default_root - existing_vhosts = {b'': default_root} - - for host_name, site_name in self.options["vhosts_dict"].items(): - if site_name == C.SITE_NAME_DEFAULT: - raise ValueError( - f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict") - encoded_site_name = site_name.encode('utf-8') - try: - site_path = self.renderer.sites_paths[site_name] - except KeyError: - log.warning(_( - "host {host_name} link to non existing site {site_name}, ignoring " - "it").format(host_name=host_name, site_name=site_name)) - continue - if encoded_site_name in existing_vhosts: - # we have an alias host, we re-use existing resource - res = existing_vhosts[encoded_site_name] - else: - # for root path we first check if there is a global static dir - # if not, we use default template's static dir - root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR) - if not os.path.isdir(root_path): - root_path = os.path.join( - site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT, - C.TEMPLATE_STATIC_DIR) - res = LiberviaRootResource( - host=self, - host_name=host_name, - site_name=site_name, - site_path=site_path, - path=root_path) - - existing_vhosts[encoded_site_name] = res - - if self.options['dev-mode']: - self.files_watcher.watch_dir( - site_path, auto_add=True, recursive=True, - callback=LiberviaPage.on_file_change, site_root=res, - # FIXME: site_path should always be a Path, check code above and - # in template module - site_path=Path(site_path)) - - LiberviaPage.import_pages(self, res) - # FIXME: default pages are accessible if not overriden by external website - # while necessary for login or re-using existing pages - # we may want to disable access to the page by direct URL - # (e.g. /blog disabled except if called by external site) - LiberviaPage.import_pages(self, res, root_path=default_site_path) - tasks_manager = TasksManager(self, res) - await tasks_manager.parse_tasks() - await tasks_manager.run_tasks() - await res._set_menu(self.options["menu_json"]) - - self.vhost_root.addHost(host_name.encode('utf-8'), res) - - templates_res = web_resource.Resource() - self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res) - for site_name, site_path in self.renderer.sites_paths.items(): - templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(), - static.File(site_path)) - - d = self.bridge_call("namespaces_get") - d.addCallback(self._namespaces_get_cb) - d.addErrback(self._namespaces_get_eb) - - # websocket - if self.options["connection_type"] in ("https", "both"): - wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True) - self.put_child_all(b'wss', wss) - if self.options["connection_type"] in ("http", "both"): - ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False) - self.put_child_all(b'ws', ws) - - # following signal is needed for cache handling in Libervia pages - self.bridge.register_signal( - "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin" - ) - self.bridge.register_signal( - "message_new", partial(self.on_signal, "message_new") - ) - self.bridge.register_signal( - "call_accepted", partial(self.on_signal, "call_accepted"), "plugin" - ) - self.bridge.register_signal( - "call_ended", partial(self.on_signal, "call_ended"), "plugin" - ) - self.bridge.register_signal( - "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin" - ) - self.bridge.register_signal( - "action_new", self.action_new_handler, - ) - - # libervia applications handling - self.bridge.register_signal( - "application_started", self.application_started_handler, "plugin" - ) - self.bridge.register_signal( - "application_error", self.application_error_handler, "plugin" - ) - - # Progress handling - self.bridge.register_signal( - "progress_started", partial(ProgressHandler._signal, "started") - ) - self.bridge.register_signal( - "progress_finished", partial(ProgressHandler._signal, "finished") - ) - self.bridge.register_signal( - "progress_error", partial(ProgressHandler._signal, "error") - ) - - # media dirs - # FIXME: get rid of dirname and "/" in C.XXX_DIR - self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'), - ProtectedFile(self.media_dir)) - - self.cache_resource = web_resource.NoResource() - self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource) - self.cache_resource.putChild( - b"common", ProtectedFile(str(self.cache_root_dir / Path("common")))) - - # redirections - for root in self.roots: - await root._init_redirections(self.options) - - # no need to keep url_redirections_dict, it will not be used anymore - del self.options["url_redirections_dict"] - - server.Request.defaultContentType = "text/html; charset=utf-8" - wrapped = web_resource.EncodingResourceWrapper( - self.vhost_root, [server.GzipEncoderFactory()] - ) - self.site = server.Site(wrapped) - self.site.sessionFactory = WebSession - - def _bridge_cb(self): - del self._bridge_retry - self.bridge.ready_get( - lambda: self.initialised.callback(None), - lambda failure: self.initialised.errback(Exception(failure)), - ) - self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready())) - - def _bridge_eb(self, failure_): - if isinstance(failure_, BridgeExceptionNoService): - if self._bridge_retry: - if self._bridge_retry < 0: - print(_("Can't connect to bridge, will retry indefinitely. " - "Next try in 1s.")) - else: - self._bridge_retry -= 1 - print( - _( - "Can't connect to bridge, will retry in 1 s ({retries_left} " - "trie(s) left)." - ).format(retries_left=self._bridge_retry) - ) - time.sleep(1) - self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) - return - - print("Can't connect to SàT backend, are you sure it's launched ?") - else: - log.error("Can't connect to bridge: {}".format(failure)) - sys.exit(1) - - @property - def version(self): - """Return the short version of Libervia""" - return C.APP_VERSION - - @property - def full_version(self): - """Return the full version of Libervia (with extra data when in dev mode)""" - version = self.version - if version[-1] == "D": - # we are in debug version, we add extra data - try: - return self._version_cache - except AttributeError: - self._version_cache = "{} ({})".format( - version, utils.get_repository_data(libervia) - ) - return self._version_cache - else: - return version - - def bridge_call(self, method_name, *args, **kwargs): - """Call an asynchronous bridge method and return a deferred - - @param method_name: name of the method as a unicode - @return: a deferred which trigger the result - - """ - d = defer.Deferred() - - def _callback(*args): - if not args: - d.callback(None) - else: - if len(args) != 1: - Exception("Multiple return arguments not supported") - d.callback(args[0]) - - def _errback(failure_): - d.errback(failure.Failure(failure_)) - - kwargs["callback"] = _callback - kwargs["errback"] = _errback - getattr(self.bridge, method_name)(*args, **kwargs) - return d - - def action_new_handler( - self, - action_data_s: str, - action_id: str, - security_limit: int, - profile: str - ) -> None: - if security_limit > C.SECURITY_LIMIT: - log.debug( - f"ignoring action {action_id} due to security limit" - ) - else: - self.on_signal( - "action_new", action_data_s, action_id, security_limit, profile - ) - - def on_signal(self, signal_name, *args): - profile = args[-1] - if not profile: - log.error(f"got signal without profile: {signal_name}, {args}") - return - session_iface.WebSession.send( - profile, - "bridge", - {"signal": signal_name, "args": args} - ) - - def application_started_handler( - self, - name: str, - instance_id: str, - extra_s: str - ) -> None: - callback = self.apps_cb.pop(instance_id, None) - if callback is not None: - defer.ensureDeferred(callback(str(name), str(instance_id))) - - def application_error_handler( - self, - name: str, - instance_id: str, - extra_s: str - ) -> None: - callback = self.apps_cb.pop(instance_id, None) - if callback is not None: - extra = data_format.deserialise(extra_s) - log.error( - f"Can't start application {name}: {extra['class']}\n{extra['msg']}" - ) - - async def _logged(self, profile, request): - """Set everything when a user just logged in - - @param profile - @param request - @return: a constant indicating the state: - - C.PROFILE_LOGGED - - C.PROFILE_LOGGED_EXT_JID - @raise exceptions.ConflictError: session is already active - """ - register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile) - self.waiting_profiles.purge_request(profile) - session = request.getSession() - web_session = session_iface.IWebSession(session) - if web_session.profile: - log.error(_("/!\\ Session has already a profile, this should NEVER happen!")) - raise failure.Failure(exceptions.ConflictError("Already active")) - - # XXX: we force string because python D-Bus has its own string type (dbus.String) - # which may cause trouble when exposing it to scripts - web_session.profile = str(profile) - self.prof_connected.add(profile) - cache_dir = os.path.join( - self.cache_root_dir, "profiles", regex.path_escape(profile) - ) - # FIXME: would be better to have a global /cache URL which redirect to - # profile's cache directory, without uuid - self.cache_resource.putChild(web_session.uuid.encode('utf-8'), - ProtectedFile(cache_dir)) - log.debug( - _("profile cache resource added from {uuid} to {path}").format( - uuid=web_session.uuid, path=cache_dir - ) - ) - - def on_expire(): - log.info("Session expired (profile={profile})".format(profile=profile)) - self.cache_resource.delEntity(web_session.uuid.encode('utf-8')) - log.debug( - _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid) - ) - web_session.on_expire() - if web_session.ws_socket is not None: - web_session.ws_socket.close() - # and now we disconnect the profile - self.bridge_call("disconnect", profile) - - session.notifyOnExpire(on_expire) - - # FIXME: those session infos should be returned by connect or is_connected - infos = await self.bridge_call("session_infos_get", profile) - web_session.jid = jid.JID(infos["jid"]) - own_bare_jid_s = web_session.jid.userhost() - own_id_raw = await self.bridge_call( - "identity_get", own_bare_jid_s, [], True, profile) - web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw) - web_session.backend_started = int(infos["started"]) - - state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED - return state - - @defer.inlineCallbacks - def connect(self, request, login, password): - """log user in - - If an other user was already logged, it will be unlogged first - @param request(server.Request): request linked to the session - @param login(unicode): user login - can be profile name - can be profile@[libervia_domain.ext] - can be a jid (a new profile will be created with this jid if needed) - @param password(unicode): user password - @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else - self._logged value - @raise exceptions.DataError: invalid login - @raise exceptions.ProfileUnknownError: this login doesn't exist - @raise exceptions.PermissionError: a login is not accepted (e.g. empty password - not allowed) - @raise exceptions.NotReady: a profile connection is already waiting - @raise exceptions.TimeoutError: didn't received and answer from bridge - @raise exceptions.InternalError: unknown error - @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password - @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password - """ - - # XXX: all security checks must be done here, even if present in javascript - if login.startswith("@"): - raise failure.Failure(exceptions.DataError("No profile_key allowed")) - - if login.startswith("guest@@") and login.count("@") == 2: - log.debug("logging a guest account") - elif "@" in login: - if login.count("@") != 1: - raise failure.Failure( - exceptions.DataError("Invalid login: {login}".format(login=login)) - ) - try: - login_jid = jid.JID(login) - except (RuntimeError, jid.InvalidFormat, AttributeError): - raise failure.Failure(exceptions.DataError("No profile_key allowed")) - - # FIXME: should it be cached? - new_account_domain = yield self.bridge_call("account_domain_new_get") - - if login_jid.host == new_account_domain: - # redirect "user@libervia.org" to the "user" profile - login = login_jid.user - login_jid = None - else: - login_jid = None - - try: - profile = yield self.bridge_call("profile_name_get", login) - except Exception: # XXX: ProfileUnknownError wouldn't work, it's encapsulated - # FIXME: find a better way to handle bridge errors - if ( - login_jid is not None and login_jid.user - ): # try to create a new sat profile using the XMPP credentials - if not self.options["allow_registration"]: - log.warning( - "Trying to register JID account while registration is not " - "allowed") - raise failure.Failure( - exceptions.DataError( - "JID login while registration is not allowed" - ) - ) - profile = login # FIXME: what if there is a resource? - connect_method = "credentials_xmpp_connect" - register_with_ext_jid = True - else: # non existing username - raise failure.Failure(exceptions.ProfileUnknownError()) - else: - if profile != login or ( - not password - and profile - not in self.options["empty_password_allowed_warning_dangerous_list"] - ): - # profiles with empty passwords are restricted to local frontends - raise exceptions.PermissionError - register_with_ext_jid = False - - connect_method = "connect" - - # we check if there is not already an active session - web_session = session_iface.IWebSession(request.getSession()) - if web_session.profile: - # yes, there is - if web_session.profile != profile: - # it's a different profile, we need to disconnect it - log.warning(_( - "{new_profile} requested login, but {old_profile} was already " - "connected, disconnecting {old_profile}").format( - old_profile=web_session.profile, new_profile=profile)) - self.purge_session(request) - - if self.waiting_profiles.get_request(profile): - # FIXME: check if and when this can happen - raise failure.Failure(exceptions.NotReady("Already waiting")) - - self.waiting_profiles.set_request(request, profile, register_with_ext_jid) - try: - connected = yield self.bridge_call(connect_method, profile, password) - except Exception as failure_: - fault = getattr(failure_, 'classname', None) - self.waiting_profiles.purge_request(profile) - if fault in ("PasswordError", "ProfileUnknownError"): - log.info("Profile {profile} doesn't exist or the submitted password is " - "wrong".format( profile=profile)) - raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR)) - elif fault == "SASLAuthError": - log.info("The XMPP password of profile {profile} is wrong" - .format(profile=profile)) - raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR)) - elif fault == "NoReply": - log.info(_("Did not receive a reply (the timeout expired or the " - "connection is broken)")) - raise exceptions.TimeOutError - elif fault is None: - log.info(_("Unexepected failure: {failure_}").format(failure_=failure)) - raise failure_ - else: - log.error('Unmanaged fault class "{fault}" in errback for the ' - 'connection of profile {profile}'.format( - fault=fault, profile=profile)) - raise failure.Failure(exceptions.InternalError(fault)) - - if connected: - # profile is already connected in backend - # do we have a corresponding session in Libervia? - web_session = session_iface.IWebSession(request.getSession()) - if web_session.profile: - # yes, session is active - if web_session.profile != profile: - # existing session should have been ended above - # so this line should never be reached - log.error(_( - "session profile [{session_profile}] differs from login " - "profile [{profile}], this should not happen!") - .format(session_profile=web_session.profile, profile=profile)) - raise exceptions.InternalError("profile mismatch") - defer.returnValue(C.SESSION_ACTIVE) - log.info( - _( - "profile {profile} was already connected in backend".format( - profile=profile - ) - ) - ) - # no, we have to create it - - state = yield defer.ensureDeferred(self._logged(profile, request)) - defer.returnValue(state) - - def register_new_account(self, request, login, password, email): - """Create a new account, or return error - @param request(server.Request): request linked to the session - @param login(unicode): new account requested login - @param email(unicode): new account email - @param password(unicode): new account password - @return(unicode): a constant indicating the state: - - C.BAD_REQUEST: something is wrong in the request (bad arguments) - - C.INVALID_INPUT: one of the data is not valid - - C.REGISTRATION_SUCCEED: new account has been successfully registered - - C.ALREADY_EXISTS: the given profile already exists - - C.INTERNAL_ERROR or any unmanaged fault string - @raise PermissionError: registration is now allowed in server configuration - """ - if not self.options["allow_registration"]: - log.warning( - _("Registration received while it is not allowed, hack attempt?") - ) - raise failure.Failure( - exceptions.PermissionError("Registration is not allowed on this server") - ) - - if ( - not re.match(C.REG_LOGIN_RE, login) - or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE) - or len(password) < C.PASSWORD_MIN_LENGTH - ): - return C.INVALID_INPUT - - def registered(result): - return C.REGISTRATION_SUCCEED - - def registering_error(failure_): - # FIXME: better error handling for bridge error is needed - status = failure_.value.fullname.split('.')[-1] - if status == "ConflictError": - return C.ALREADY_EXISTS - elif status == "InvalidCertificate": - return C.INVALID_CERTIFICATE - elif status == "InternalError": - return C.INTERNAL_ERROR - else: - log.error( - _("Unknown registering error status: {status}\n{traceback}").format( - status=status, traceback=failure_.value.message - ) - ) - return status - - d = self.bridge_call("libervia_account_register", email, password, login) - d.addCallback(registered) - d.addErrback(registering_error) - return d - - def addCleanup(self, callback, *args, **kwargs): - """Add cleaning method to call when service is stopped - - cleaning method will be called in reverse order of they insertion - @param callback: callable to call on service stop - @param *args: list of arguments of the callback - @param **kwargs: list of keyword arguments of the callback""" - self._cleanup.insert(0, (callback, args, kwargs)) - - def init_eb(self, failure): - from twisted.application import app - if failure.check(SysExit): - if failure.value.message: - log.error(failure.value.message) - app._exitCode = failure.value.exit_code - reactor.stop() - else: - log.error(_("Init error: {msg}").format(msg=failure)) - app._exitCode = C.EXIT_INTERNAL_ERROR - reactor.stop() - return failure - - def _build_only_cb(self, __): - log.info(_("Stopping here due to --build-only flag")) - self.stop() - - def startService(self): - """Connect the profile for Libervia and start the HTTP(S) server(s)""" - self._init() - if self.options['build-only']: - self.initialised.addCallback(self._build_only_cb) - else: - self.initialised.addCallback(self._start_service) - self.initialised.addErrback(self.init_eb) - - ## URLs ## - - def put_child_sat(self, path, resource): - """Add a child to the sat resource""" - if not isinstance(path, bytes): - raise ValueError("path must be specified in bytes") - self.sat_root.putChild(path, resource) - - def put_child_all(self, path, resource): - """Add a child to all vhost root resources""" - if not isinstance(path, bytes): - raise ValueError("path must be specified in bytes") - # we wrap before calling putChild, to avoid having useless multiple instances - # of the resource - # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) - wrapped_res = web_resource.EncodingResourceWrapper( - resource, [server.GzipEncoderFactory()]) - for root in self.roots: - root.putChild(path, wrapped_res) - - def get_build_path(self, site_name: str, dev: bool=False) -> Path: - """Generate build path for a given site name - - @param site_name: name of the site - @param dev: return dev build dir if True, production one otherwise - dev build dir is used for installing dependencies needed temporarily (e.g. - to compile files), while production build path is the one served by the - HTTP server, where final files are downloaded. - @return: path to the build directory - """ - sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR - build_path_elts = [ - config.config_get(self.main_conf, "", "local_dir"), - C.CACHE_DIR, - C.LIBERVIA_CACHE, - sub_dir, - regex.path_escape(site_name or C.SITE_NAME_DEFAULT)] - build_path = Path("/".join(build_path_elts)) - return build_path.expanduser().resolve() - - def get_ext_base_url_data(self, request): - """Retrieve external base URL Data - - this method try to retrieve the base URL found by external user - It does by checking in this order: - - base_url_ext option from configuration - - proxy x-forwarder-host headers - - URL of the request - @return (urlparse.SplitResult): SplitResult instance with only scheme and - netloc filled - """ - ext_data = self.base_url_ext_data - url_path = request.URLPath() - - try: - forwarded = request.requestHeaders.getRawHeaders( - "forwarded" - )[0] - except TypeError: - # we try deprecated headers - try: - proxy_netloc = request.requestHeaders.getRawHeaders( - "x-forwarded-host" - )[0] - except TypeError: - proxy_netloc = None - try: - proxy_scheme = request.requestHeaders.getRawHeaders( - "x-forwarded-proto" - )[0] - except TypeError: - proxy_scheme = None - else: - fwd_data = { - k.strip(): v.strip() - for k,v in (d.split("=") for d in forwarded.split(";")) - } - proxy_netloc = fwd_data.get("host") - proxy_scheme = fwd_data.get("proto") - - return urllib.parse.SplitResult( - ext_data.scheme or proxy_scheme or url_path.scheme.decode(), - ext_data.netloc or proxy_netloc or url_path.netloc.decode(), - ext_data.path or "/", - "", - "", - ) - - def get_ext_base_url( - self, - request: server.Request, - path: str = "", - query: str = "", - fragment: str = "", - scheme: Optional[str] = None, - ) -> str: - """Get external URL according to given elements - - external URL is the URL seen by external user - @param path: same as for urlsplit.urlsplit - path will be prefixed to follow found external URL if suitable - @param params: same as for urlsplit.urlsplit - @param query: same as for urlsplit.urlsplit - @param fragment: same as for urlsplit.urlsplit - @param scheme: if not None, will override scheme from base URL - @return: external URL - """ - split_result = self.get_ext_base_url_data(request) - return urllib.parse.urlunsplit( - ( - split_result.scheme if scheme is None else scheme, - split_result.netloc, - os.path.join(split_result.path, path), - query, - fragment, - ) - ) - - def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str: - """check is a part of the URL prefix is redirected then replace it - - @param vhost_root: root of this virtual host - @param url_path: path of the url to check - @return: possibly redirected URL which should link to the same location - """ - inv_redirections = vhost_root.inv_redirections - url_parts = url_path.strip("/").split("/") - for idx in range(len(url_parts), -1, -1): - test_url = "/" + "/".join(url_parts[:idx]) - if test_url in inv_redirections: - rem_url = url_parts[idx:] - return os.path.join( - "/", "/".join([inv_redirections[test_url]] + rem_url) - ) - return url_path - - ## Sessions ## - - def purge_session(self, request): - """helper method to purge a session during request handling""" - session = request.session - if session is not None: - log.debug(_("session purge")) - web_session = self.get_session_data(request, session_iface.IWebSession) - socket = web_session.ws_socket - if socket is not None: - socket.close() - session.ws_socket = None - session.expire() - # FIXME: not clean but it seems that it's the best way to reset - # session during request handling - request._secureSession = request._insecureSession = None - - def get_session_data(self, request, *args): - """helper method to retrieve session data - - @param request(server.Request): request linked to the session - @param *args(zope.interface.Interface): interface of the session to get - @return (iterator(data)): requested session data - """ - session = request.getSession() - if len(args) == 1: - return args[0](session) - else: - return (iface(session) for iface in args) - - @defer.inlineCallbacks - def get_affiliation(self, request, service, node): - """retrieve pubsub node affiliation for current user - - use cache first, and request pubsub service if not cache is found - @param request(server.Request): request linked to the session - @param service(jid.JID): pubsub service - @param node(unicode): pubsub node - @return (unicode): affiliation - """ - web_session = self.get_session_data(request, session_iface.IWebSession) - if web_session.profile is None: - raise exceptions.InternalError("profile must be set to use this method") - affiliation = web_session.get_affiliation(service, node) - if affiliation is not None: - defer.returnValue(affiliation) - else: - try: - affiliations = yield self.bridge_call( - "ps_affiliations_get", service.full(), node, web_session.profile - ) - except Exception as e: - log.warning( - "Can't retrieve affiliation for {service}/{node}: {reason}".format( - service=service, node=node, reason=e - ) - ) - affiliation = "" - else: - try: - affiliation = affiliations[node] - except KeyError: - affiliation = "" - web_session.set_affiliation(service, node, affiliation) - defer.returnValue(affiliation) - - ## Websocket (dynamic pages) ## - - def get_websocket_url(self, request): - base_url_split = self.get_ext_base_url_data(request) - if base_url_split.scheme.endswith("s"): - scheme = "wss" - else: - scheme = "ws" - - return self.get_ext_base_url(request, path=scheme, scheme=scheme) - - - ## Various utils ## - - def get_http_date(self, timestamp=None): - now = time.gmtime(timestamp) - fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format( - day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1] - ) - return time.strftime(fmt_date, now) - - ## service management ## - - def _start_service(self, __=None): - """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. - - @raise ImportError: OpenSSL is not available - @raise IOError: the certificate file doesn't exist - @raise OpenSSL.crypto.Error: the certificate file is invalid - """ - # now that we have service profile connected, we add resource for its cache - service_path = regex.path_escape(C.SERVICE_PROFILE) - cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path) - self.cache_resource.putChild(service_path.encode('utf-8'), - ProtectedFile(cache_dir)) - self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path) - session_iface.WebSession.service_cache_url = self.service_cache_url - - if self.options["connection_type"] in ("https", "both"): - try: - tls.tls_options_check(self.options) - context_factory = tls.get_tls_context_factory(self.options) - except exceptions.ConfigError as e: - log.warning( - f"There is a problem in TLS settings in your configuration file: {e}") - self.quit(2) - except exceptions.DataError as e: - log.warning( - f"Can't set TLS: {e}") - self.quit(1) - reactor.listenSSL(self.options["port_https"], self.site, context_factory) - if self.options["connection_type"] in ("http", "both"): - if ( - self.options["connection_type"] == "both" - and self.options["redirect_to_https"] - ): - reactor.listenTCP( - self.options["port"], - server.Site( - RedirectToHTTPS( - self.options["port"], self.options["port_https_ext"] - ) - ), - ) - else: - reactor.listenTCP(self.options["port"], self.site) - - @defer.inlineCallbacks - def stopService(self): - log.info(_("launching cleaning methods")) - for callback, args, kwargs in self._cleanup: - callback(*args, **kwargs) - try: - yield self.bridge_call("disconnect", C.SERVICE_PROFILE) - except Exception: - log.warning("Can't disconnect service profile") - - def run(self): - reactor.run() - - def stop(self): - reactor.stop() - - def quit(self, exit_code=None): - """Exit app when reactor is running - - @param exit_code(None, int): exit code - """ - self.stop() - sys.exit(exit_code or 0) - - -class RedirectToHTTPS(web_resource.Resource): - def __init__(self, old_port, new_port): - web_resource.Resource.__init__(self) - self.isLeaf = True - self.old_port = old_port - self.new_port = new_port - - def render(self, request): - netloc = request.URLPath().netloc.decode().replace( - f":{self.old_port}", f":{self.new_port}" - ) - url = f"https://{netloc}{request.uri.decode()}" - return web_util.redirectTo(url.encode(), request) - - -registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession) -registerAdapter( - session_iface.SATGuestSession, server.Session, session_iface.ISATGuestSession -)
--- a/libervia/server/session_iface.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a SàT frontend -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -from collections import OrderedDict, deque -import os.path -import time -from typing import List, Dict, Optional - -import shortuuid -from zope.interface import Attribute, Interface -from zope.interface import implementer - -from sat.core.log import getLogger - -from libervia.server.classes import Notification -from libervia.server.constants import Const as C - - -log = getLogger(__name__) - -FLAGS_KEY = "_flags" -NOTIFICATIONS_KEY = "_notifications" -MAX_CACHE_AFFILIATIONS = 100 # number of nodes to keep in cache - - -class IWebSession(Interface): - profile = Attribute("Sat profile") - jid = Attribute("JID associated with the profile") - uuid = Attribute("uuid associated with the profile session") - identities = Attribute("Identities of XMPP entities") - - -@implementer(IWebSession) -class WebSession: - profiles_map: Dict[Optional[str], List["WebSession"]] = {} - - def __init__(self, session): - self._profile = None - self.jid = None - self.started = time.time() - # time when the backend session was started - self.backend_started = None - self.uuid = str(shortuuid.uuid()) - self.identities = {} - self.csrf_token = str(shortuuid.uuid()) - self.ws_token = str(shortuuid.uuid()) - self.ws_socket = None - self.ws_buffer = deque(maxlen=200) - self.locale = None # i18n of the pages - self.theme = None - self.pages_data = {} # used to keep data accross reloads (key is page instance) - self.affiliations = OrderedDict() # cache for node affiliations - self.profiles_map.setdefault(C.SERVICE_PROFILE, []).append(self) - log.debug( - f"session started for {C.SERVICE_PROFILE} " - f"({len(self.get_profile_sessions(C.SERVICE_PROFILE))} session(s) active)" - ) - - @property - def profile(self) -> Optional[str]: - return self._profile - - @profile.setter - def profile(self, profile: Optional[str]) -> None: - old_profile = self._profile or C.SERVICE_PROFILE - new_profile = profile or C.SERVICE_PROFILE - try: - self.profiles_map[old_profile].remove(self) - except (ValueError, KeyError): - log.warning(f"session was not registered for profile {old_profile}") - else: - nb_session_old = len(self.get_profile_sessions(old_profile)) - log.debug(f"{old_profile} has now {nb_session_old} session(s) active") - - self._profile = profile - self.profiles_map.setdefault(new_profile, []).append(self) - nb_session_new = len(self.get_profile_sessions(new_profile)) - log.debug(f"{new_profile} has now {nb_session_new} session(s) active") - - @property - def cache_dir(self): - if self.profile is None: - return self.service_cache_url + "/" - return os.path.join("/", C.CACHE_DIR, self.uuid) + "/" - - @property - def connected(self): - return self.profile is not None - - @property - def guest(self): - """True if this is a guest session""" - if self.profile is None: - return False - else: - return self.profile.startswith("guest@@") - - @classmethod - def send(cls, profile: str, data_type: str, data: dict) -> None: - """Send a message to all session - - If the session doesn't have an active websocket, the message is buffered until a - socket is available - """ - for session in cls.profiles_map.get(profile, []): - if session.ws_socket is None or not session.ws_socket.init_ok: - session.ws_buffer.append({"data_type": data_type, "data": data}) - else: - session.ws_socket.send(data_type, data) - - def on_expire(self) -> None: - profile = self._profile or C.SERVICE_PROFILE - try: - self.profiles_map[profile].remove(self) - except (ValueError, KeyError): - log.warning(f"session was not registered for profile {profile}") - else: - nb_session = len(self.get_profile_sessions(profile)) - log.debug( - f"Session for profile {profile} expired. {profile} has now {nb_session} " - f"session(s) active." - ) - - @classmethod - def get_profile_sessions(cls, profile: str) -> List["WebSession"]: - return cls.profiles_map.get(profile, []) - - def get_page_data(self, page, key): - """get session data for a page - - @param page(LiberviaPage): instance of the page - @param key(object): data key - return (None, object): value of the key - None if not found or page_data doesn't exist - """ - return self.pages_data.get(page, {}).get(key) - - def pop_page_data(self, page, key, default=None): - """like get_page_data, but remove key once value is gotten - - @param page(LiberviaPage): instance of the page - @param key(object): data key - @param default(object): value to return if key is not found - @return (object): found value or default - """ - page_data = self.pages_data.get(page) - if page_data is None: - return default - value = page_data.pop(key, default) - if not page_data: - # no need to keep unused page_data - del self.pages_data[page] - return value - - def set_page_data(self, page, key, value): - """set data to persist on reload - - @param page(LiberviaPage): instance of the page - @param key(object): data key - @param value(object): value to set - @return (object): set value - """ - page_data = self.pages_data.setdefault(page, {}) - page_data[key] = value - return value - - def set_page_flag(self, page, flag): - """set a flag for this page - - @param page(LiberviaPage): instance of the page - @param flag(unicode): flag to set - """ - flags = self.get_page_data(page, FLAGS_KEY) - if flags is None: - flags = self.set_page_data(page, FLAGS_KEY, set()) - flags.add(flag) - - def pop_page_flag(self, page, flag): - """return True if flag is set - - flag is removed if it was set - @param page(LiberviaPage): instance of the page - @param flag(unicode): flag to set - @return (bool): True if flaag was set - """ - page_data = self.pages_data.get(page, {}) - flags = page_data.get(FLAGS_KEY) - if flags is None: - return False - if flag in flags: - flags.remove(flag) - # we remove data if they are not used anymore - if not flags: - del page_data[FLAGS_KEY] - if not page_data: - del self.pages_data[page] - return True - else: - return False - - def set_page_notification(self, page, message, level=C.LVL_INFO): - """set a flag for this page - - @param page(LiberviaPage): instance of the page - @param flag(unicode): flag to set - """ - notif = Notification(message, level) - notifs = self.get_page_data(page, NOTIFICATIONS_KEY) - if notifs is None: - notifs = self.set_page_data(page, NOTIFICATIONS_KEY, []) - notifs.append(notif) - - def pop_page_notifications(self, page): - """Return and remove last page notification - - @param page(LiberviaPage): instance of the page - @return (list[Notification]): notifications if any - """ - page_data = self.pages_data.get(page, {}) - notifs = page_data.get(NOTIFICATIONS_KEY) - if not notifs: - return [] - ret = notifs[:] - del notifs[:] - return ret - - def get_affiliation(self, service, node): - """retrieve affiliation for a pubsub node - - @param service(jid.JID): pubsub service - @param node(unicode): pubsub node - @return (unicode, None): affiliation, or None if it is not in cache - """ - if service.resource: - raise ValueError("Service must not have a resource") - if not node: - raise ValueError("node must be set") - try: - affiliation = self.affiliations.pop((service, node)) - except KeyError: - return None - else: - # we replace at the top to get the most recently used on top - # so less recently used will be removed if cache is full - self.affiliations[(service, node)] = affiliation - return affiliation - - def set_affiliation(self, service, node, affiliation): - """cache affiliation for a node - - will empty cache when it become too big - @param service(jid.JID): pubsub service - @param node(unicode): pubsub node - @param affiliation(unicode): affiliation to this node - """ - if service.resource: - raise ValueError("Service must not have a resource") - if not node: - raise ValueError("node must be set") - self.affiliations[(service, node)] = affiliation - while len(self.affiliations) > MAX_CACHE_AFFILIATIONS: - self.affiliations.popitem(last=False) - - -class ISATGuestSession(Interface): - id = Attribute("UUID of the guest") - data = Attribute("data associated with the guest") - - -@implementer(ISATGuestSession) -class SATGuestSession(object): - - def __init__(self, session): - self.id = None - self.data = None
--- a/libervia/server/tasks/implicit/task_brython.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,149 +0,0 @@ -#!/ur/bin/env python3 - -from ast import literal_eval -import json -from pathlib import Path -import shutil -from typing import Any, Dict - -from sat.core import exceptions -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.tools.common import utils - -from libervia.server.classes import Script -from libervia.server.constants import Const as C -from libervia.server.tasks import task - - -log = getLogger(__name__) - - -class Task(task.Task): - - def prepare(self): - if "brython" not in self.resource.browser_modules: - raise exceptions.CancelError("No brython module found") - - brython_js = self.build_path / "brython.js" - if not brython_js.is_file(): - installed_ver = None - else: - with brython_js.open() as f: - for line in f: - if line.startswith('// implementation ['): - installed_ver = literal_eval(line[18:])[:3] - log.debug( - f"brython v{'.'.join(str(v) for v in installed_ver)} already " - f"installed") - break - else: - log.warning( - f"brython file at {brython_js} doesn't has implementation " - f"version" - ) - installed_ver = None - - try: - import brython - try: - from brython.__main__ import implementation - except ImportError: - from brython.version import implementation - except ModuleNotFoundError as e: - log.error('"brython" module is missing, can\'t use browser code for Brython') - raise e - ver = [int(v) for v in implementation.split('.')[:3]] - if ver != installed_ver: - log.info(_("Installing Brython v{version}").format( - version='.'.join(str(v) for v in ver))) - data_path = Path(brython.__file__).parent / 'data' - # shutil has blocking method, but the task is run before we start - # the web server, so it's not a big deal - shutil.copyfile(data_path / "brython.js", brython_js) - shutil.copy(data_path / "brython_stdlib.js", self.build_path) - else: - log.debug("Brython is already installed") - - self.WATCH_DIRS = [] - self.set_common_scripts() - - def set_common_scripts(self): - for dyn_data in self.resource.browser_modules["brython"]: - url_hash = dyn_data['url_hash'] - import_url = f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}" - dyn_data.setdefault('scripts', utils.OrderedSet()).update([ - Script(src=f"/{C.BUILD_DIR}/brython.js"), - Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"), - ]) - dyn_data.setdefault('template', {})['body_onload'] = self.get_body_onload( - extra_path=[import_url]) - self.WATCH_DIRS.append(dyn_data['path'].resolve()) - - def get_body_onload(self, debug=True, cache=True, extra_path=None): - on_load_opts: Dict[str, Any] = {"pythonpath": [f"/{C.BUILD_DIR}"]} - if debug: - on_load_opts["debug"] = 1 - if cache: - on_load_opts["cache"] = True - if extra_path is not None: - on_load_opts["pythonpath"].extend(extra_path) - - return f"brython({json.dumps(on_load_opts)})" - - def copy_files(self, files_paths, dest): - for p in files_paths: - log.debug(f"copying {p}") - if p.is_dir(): - if p.name == '__pycache__': - continue - shutil.copytree(p, dest / p.name) - else: - shutil.copy(p, dest) - - async def on_dir_event(self, host, filepath, flags): - self.set_common_scripts() - await self.manager.run_task_instance(self) - - def start(self): - dyn_path = self.build_path / C.BUILD_DIR_DYN - for dyn_data in self.resource.browser_modules["brython"]: - url_hash = dyn_data['url_hash'] - if url_hash is None: - # root modules - url_prefix = dyn_data.get('url_prefix') - if url_prefix is None: - dest = self.build_path - init_dest_url = f"/{C.BUILD_DIR}/__init__.py" - else: - dest = self.build_path / url_prefix - dest.mkdir(exist_ok = True) - init_dest_url = f"/{C.BUILD_DIR}/{url_prefix}/__init__.py" - - self.copy_files(dyn_data['path'].glob('*py'), dest) - - init_file = dyn_data['path'] / '__init__.py' - if init_file.is_file(): - self.resource.dyn_data_common['scripts'].update([ - Script(src=f"/{C.BUILD_DIR}/brython.js"), - Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"), - Script(type='text/python', src=init_dest_url) - ]) - self.resource.dyn_data_common.setdefault( - "template", {})['body_onload'] = self.get_body_onload() - else: - page_dyn_path = dyn_path / url_hash - log.debug(f"using dynamic path at {page_dyn_path}") - if page_dyn_path.exists(): - log.debug("cleaning existing path") - shutil.rmtree(page_dyn_path) - - page_dyn_path.mkdir(parents=True, exist_ok=True) - log.debug("copying browser python files") - self.copy_files(dyn_data['path'].iterdir(), page_dyn_path) - - script = Script( - type='text/python', - src=f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}/__init__.py" - ) - dyn_data.setdefault('scripts', utils.OrderedSet()).add(script)
--- a/libervia/server/tasks/implicit/task_js_modules.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -#!/ur/bin/env python3 - -import json -from pathlib import Path -from sat.core.i18n import _ -from sat.core.log import getLogger -from sat.core import exceptions -from libervia.server.constants import Const as C -from libervia.server.tasks import task - - -log = getLogger(__name__) - - -class Task(task.Task): - - async def prepare(self): - if "js" not in self.resource.browser_modules: - raise exceptions.CancelError("No JS module needed") - - async def start(self): - js_data = self.resource.browser_modules['js'] - package = js_data.get('package', {}) - package_path = self.build_path / 'package.json' - with package_path.open('w') as f: - json.dump(package, f) - - cmd = self.find_command('yarnpkg', 'yarn') - await self.runCommand(cmd, 'install', path=str(self.build_path)) - - try: - brython_map = js_data['brython_map'] - except KeyError: - pass - else: - log.info(_("creating JS modules mapping for Brython")) - js_modules_path = self.build_path / 'js_modules' - js_modules_path.mkdir(exist_ok=True) - init_path = js_modules_path / '__init__.py' - init_path.touch() - - for module_name, module_data in brython_map.items(): - log.debug(f"generating mapping for {module_name}") - if ' ' in module_name: - raise ValueError( - f"module {module_name!r} has space(s), it must not!") - module_path = js_modules_path / f"{module_name}.py" - if isinstance(module_data, str): - module_data = {'path': module_data} - try: - js_path = module_data.pop('path') - except KeyError: - raise ValueError( - f'module data for {module_name} must have a "path" key') - module_data['path'] = Path('node_modules') / js_path.strip(' /') - export = module_data.get('export') or [module_name] - export_objects = '\n'.join(f'{e} = window.{e}' for e in export) - extra_kwargs = {"build_dir": C.BUILD_DIR} - - with module_path.open('w') as f: - f.write(f"""\ -#!/usr/bin/env python3 -from browser import window, load -{module_data.get('extra_import', '')} - -load("{Path('/').joinpath(C.BUILD_DIR, module_data['path'])}") -{export_objects} -{module_data.get('extra_init', '').format(**extra_kwargs)} -""")
--- a/libervia/server/tasks/implicit/task_sass.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,69 +0,0 @@ -#!/ur/bin/env python3 - -import json -from sat.core.log import getLogger -from sat.core import exceptions -from libervia.server.tasks import task - - -log = getLogger(__name__) - -SASS_SUFFIXES = ('.sass', '.scss') - - -class Task(task.Task): - """Compile .sass and .scss files found in themes browser paths""" - AFTER = ['js_modules'] - - async def prepare(self): - # we look for any Sass file, and cancel this task if none is found - sass_dirs = set() - for browser_path in self.resource.browser_modules.get('themes_browser_paths', []): - for p in browser_path.iterdir(): - if p.suffix in SASS_SUFFIXES: - sass_dirs.add(browser_path) - break - - if not sass_dirs: - raise exceptions.CancelError("No Sass file found") - - # we have some Sass files, we need to install the compiler - d_path = self.resource.dev_build_path - package_path = d_path / "package.json" - try: - with package_path.open() as f: - package = json.load(f) - except FileNotFoundError: - package = {} - except Exception as e: - log.error(f"Unexepected exception while parsing package.json: {e}") - - if 'node-sass' not in package.setdefault('dependencies', {}): - package['dependencies']['node-sass'] = 'latest' - with package_path.open('w') as f: - json.dump(package, f, indent=4) - - cmd = self.find_command('yarnpkg', 'yarn') - await self.runCommand(cmd, 'install', path=str(d_path)) - - self.WATCH_DIRS = list(sass_dirs) - - async def on_dir_event(self, host, filepath, flags): - if filepath.suffix in SASS_SUFFIXES: - await self.manager.run_task_instance(self) - - async def start(self): - d_path = self.resource.dev_build_path - node_sass = d_path / 'node_modules' / 'node-sass' / 'bin' / 'node-sass' - for browser_path in self.resource.browser_modules['themes_browser_paths']: - for p in browser_path.iterdir(): - if p.suffix not in SASS_SUFFIXES: - continue - await self.runCommand( - str(node_sass), - "--omit-source-map-url", - "--output-style", "compressed", - "--output", str(self.build_path), - str(p), - path=str(self.build_path) - )
--- a/libervia/server/tasks/manager.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -import os -import os.path -from pathlib import Path -from typing import Dict -import importlib.util -from twisted.internet import defer -from sat.core.log import getLogger -from sat.core import exceptions -from sat.core.i18n import _ -from sat.tools import utils -from libervia.server.constants import Const as C -from . import implicit -from .task import Task - -log = getLogger(__name__) - -DEFAULT_SITE_LABEL = _("default site") - - -class TasksManager: - """Handle tasks of a Libervia site""" - - def __init__(self, host, site_resource): - """ - @param site_resource(LiberviaRootResource): root resource of the site to manage - """ - self.host = host - self.resource = site_resource - self.tasks_dir = self.site_path / C.TASKS_DIR - self.tasks = {} - self._build_path = None - self._current_task = None - - @property - def site_path(self): - return Path(self.resource.site_path) - - @property - def build_path(self): - """path where generated files will be build for this site""" - if self._build_path is None: - self._build_path = self.host.get_build_path(self.site_name) - return self._build_path - - @property - def site_name(self): - return self.resource.site_name - - def validate_data(self, task): - """Check workflow attributes in task""" - - for var, allowed in (("ON_ERROR", ("continue", "stop")), - ("LOG_OUTPUT", bool), - ("WATCH_DIRS", list)): - value = getattr(task, var) - - if isinstance(allowed, type): - if allowed is list and value is None: - continue - if not isinstance(value, allowed): - raise ValueError( - _("Unexpected value for {var}, {allowed} is expected.") - .format(var=var, allowed=allowed)) - else: - if not value in allowed: - raise ValueError(_("Unexpected value for {var}: {value!r}").format( - var=var, value=value)) - - async def import_task( - self, - task_name: str, - task_path: Path, - to_import: Dict[str, Path] - ) -> None: - if task_name in self.tasks: - log.debug(f"skipping task {task_name} which is already imported") - return - module_name = f"{self.site_name or C.SITE_NAME_DEFAULT}.task.{task_name}" - - spec = importlib.util.spec_from_file_location(module_name, task_path) - task_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(task_module) - task = task_module.Task(self, task_name) - if task.AFTER is not None: - for pre_task_name in task.AFTER: - log.debug( - f"task {task_name!r} must be run after {pre_task_name!r}") - try: - pre_task_path = to_import[pre_task_name] - except KeyError: - raise ValueError( - f"task {task_name!r} must be run after {pre_task_name!r}, " - f"however there is no task with such name") - await self.import_task(pre_task_name, pre_task_path, to_import) - - # we launch prepare, which is a method used to prepare - # data at runtime (e.g. set WATCH_DIRS using config) - try: - prepare = task.prepare - except AttributeError: - pass - else: - log.info(_('== preparing task "{task_name}" for {site_name} =='.format( - task_name=task_name, site_name=self.site_name or DEFAULT_SITE_LABEL))) - try: - await utils.as_deferred(prepare) - except exceptions.CancelError as e: - log.debug(f"Skipping {task_name} which cancelled itself: {e}") - return - - self.tasks[task_name] = task - self.validate_data(task) - if self.host.options['dev-mode']: - dirs = task.WATCH_DIRS or [] - for dir_ in dirs: - self.host.files_watcher.watch_dir( - dir_, auto_add=True, recursive=True, - callback=self._autorun_task, task_name=task_name) - - async def parse_tasks_dir(self, dir_path: Path) -> None: - log.debug(f"parsing tasks in {dir_path}") - tasks_paths = sorted(dir_path.glob('task_*.py')) - to_import = {} - for task_path in tasks_paths: - if not task_path.is_file(): - continue - task_name = task_path.stem[5:].lower().strip() - if not task_name: - continue - if task_name in self.tasks: - raise exceptions.ConflictError( - "A task with the name [{name}] already exists".format( - name=task_name)) - log.debug(f"task {task_name} found") - to_import[task_name] = task_path - - for task_name, task_path in to_import.items(): - await self.import_task(task_name, task_path, to_import) - - async def parse_tasks(self): - # implicit tasks are always run - implicit_path = Path(implicit.__file__).parent - await self.parse_tasks_dir(implicit_path) - # now we check if there are tasks specific to this site - if not self.tasks_dir.is_dir(): - log.debug(_("{name} has no task to launch.").format( - name = self.resource.site_name or DEFAULT_SITE_LABEL)) - return - else: - await self.parse_tasks_dir(self.tasks_dir) - - def _autorun_task(self, host, filepath, flags, task_name): - """Called when an event is received from a watched directory""" - if flags == ['create']: - return - try: - task = self.tasks[task_name] - on_dir_event_cb = task.on_dir_event - except AttributeError: - return defer.ensureDeferred(self.run_task(task_name)) - else: - return utils.as_deferred( - on_dir_event_cb, host, Path(filepath.path.decode()), flags) - - async def run_task_instance(self, task: Task) -> None: - self._current_task = task.name - log.info(_('== running task "{task_name}" for {site_name} =='.format( - task_name=task.name, site_name=self.site_name or DEFAULT_SITE_LABEL))) - os.chdir(self.site_path) - try: - await utils.as_deferred(task.start) - except Exception as e: - on_error = task.ON_ERROR - if on_error == 'stop': - raise e - elif on_error == 'continue': - log.warning(_('Task "{task_name}" failed for {site_name}: {reason}') - .format(task_name=task.name, site_name=self.site_name, reason=e)) - else: - raise exceptions.InternalError("we should never reach this point") - self._current_task = None - - async def run_task(self, task_name: str) -> None: - """Run a single task - - @param task_name(unicode): name of the task to run - """ - task = self.tasks[task_name] - await self.run_task_instance(task) - - async def run_tasks(self): - """Run all the tasks found""" - old_path = os.getcwd() - for task_name, task_value in self.tasks.items(): - await self.run_task(task_name) - os.chdir(old_path)
--- a/libervia/server/tasks/task.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -from twisted.python.procutils import which -from sat.core.log import getLogger -from sat.tools.common import async_process -from sat.core import exceptions -from sat.core.i18n import _ -from typing import Optional - -log = getLogger(__name__) - - -class Task: - """Handle tasks of a Libervia site""" - # can be "stop" or "continue" - ON_ERROR: str = "stop" - LOG_OUTPUT: bool = True - # list of directories to check for restarting this task - # Task.on_dir_event will be called if it exists, otherwise - # the task will be run and Task.start will be called - WATCH_DIRS: Optional[list] = None - # list of task names which must be prepared/started before this one - AFTER: Optional[list] = None - - def __init__(self, manager, task_name): - self.manager = manager - self.name = task_name - - @property - def host(self): - return self.manager.host - - @property - def resource(self): - return self.manager.resource - - @property - def site_path(self): - return self.manager.site_path - - @property - def build_path(self): - """path where generated files will be build for this site""" - return self.manager.build_path - - def config_get(self, key, default=None, value_type=None): - return self.host.config_get(self.resource, key=key, default=default, - value_type=value_type) - - @property - def site_name(self): - return self.resource.site_name - - def find_command(self, name, *args): - """Find full path of a shell command - - @param name(unicode): name of the command to find - @param *args(unicode): extra names the command may have - @return (unicode): full path of the command - @raise exceptions.NotFound: can't find this command - """ - names = (name,) + args - for n in names: - try: - cmd_path = which(n)[0] - except IndexError: - pass - else: - return cmd_path - raise exceptions.NotFound(_( - "Can't find {name} command, did you install it?").format(name=name)) - - def runCommand(self, command, *args, **kwargs): - kwargs['verbose'] = self.LOG_OUTPUT - return async_process.CommandProtocol.run(command, *args, **kwargs)
--- a/libervia/server/utils.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 - - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -from sat.core.i18n import _ -from twisted.internet import reactor -from twisted.internet import defer -from sat.core import exceptions -from sat.core.log import getLogger -import urllib.request, urllib.parse, urllib.error - -log = getLogger(__name__) - - -def quote(value, safe="@"): - """shortcut to quote an unicode value for URL""" - return urllib.parse.quote(value, safe=safe) - - -class ProgressHandler(object): - """class to help the management of progressions""" - - handlers = {} - - def __init__(self, host, progress_id, profile): - self.host = host - self.progress_id = progress_id - self.profile = profile - - @classmethod - def _signal(cls, name, progress_id, data, profile): - handlers = cls.handlers - if profile in handlers and progress_id in handlers[profile]: - handler_data = handlers[profile][progress_id] - timeout = handler_data["timeout"] - if timeout.active(): - timeout.cancel() - cb = handler_data[name] - if cb is not None: - cb(data) - if name == "started": - pass - elif name == "finished": - handler_data["deferred"].callback(data) - handler_data["instance"].unregister_handler() - elif name == "error": - handler_data["deferred"].errback(Exception(data)) - handler_data["instance"].unregister_handler() - else: - log.error("unexpected signal: {name}".format(name=name)) - - def _timeout(self): - log.warning( - _( - "No progress received, cancelling handler: {progress_id} [{profile}]" - ).format(progress_id=self.progress_id, profile=self.profile) - ) - - def unregister_handler(self): - """remove a previously registered handler""" - try: - del self.handlers[self.profile][self.progress_id] - except KeyError: - log.warning( - _("Trying to remove unknown handler: {progress_id} [{profile}]").format( - progress_id=self.progress_id, profile=self.profile - ) - ) - else: - if not self.handlers[self.profile]: - self.handlers[self.profile] - - def register(self, started_cb=None, finished_cb=None, error_cb=None, timeout=30): - """register the signals to handle progression - - @param started_cb(callable, None): method to call when progress_started signal is received - @param finished_cb(callable, None): method to call when progress_finished signal is received - @param error_cb(callable, None): method to call when progress_error signal is received - @param timeout(int): progress time out - if nothing happen in this progression during this delay, - an exception is raised - @return (D(dict[unicode,unicode])): a deferred called when progression is finished - """ - handler_data = self.handlers.setdefault(self.profile, {}).setdefault( - self.progress_id, {} - ) - if handler_data: - raise exceptions.ConflictError( - "There is already one handler for this progression" - ) - handler_data["instance"] = self - deferred = handler_data["deferred"] = defer.Deferred() - handler_data["started"] = started_cb - handler_data["finished"] = finished_cb - handler_data["error"] = error_cb - handler_data["timeout"] = reactor.callLater(timeout, self._timeout) - return deferred - - -class SubPage(str): - """use to mark subpages when generating a page path"""
--- a/libervia/server/websockets.py Thu Jun 01 21:42:02 2023 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero 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 Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - - -import json -from typing import Optional - -from autobahn.twisted import websocket -from autobahn.twisted import resource as resource -from autobahn.websocket import types -from sat.core import exceptions -from sat.core.i18n import _ -from sat.core.log import getLogger - -from . import session_iface -from .constants import Const as C - -log = getLogger(__name__) - -host = None - - -class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol): - - def __init__(self): - super().__init__() - self._init_ok: bool = False - self.__profile: Optional[str] = None - self.__session: Optional[session_iface.WebSession] = None - - @property - def init_ok(self): - return self._init_ok - - def send(self, data_type: str, data: dict) -> None: - """Send data to frontend""" - if not self._init_ok and data_type != "error": - raise exceptions.InternalError( - "send called when not initialized, this should not happend! Please use " - "WebSession.send which takes care of sending correctly the data to all " - "sessions." - ) - - data_root = { - "type": data_type, - "data": data - } - self.sendMessage(json.dumps(data_root, ensure_ascii=False).encode()) - - def close(self) -> None: - log.debug(f"closing websocket for profile {self.__profile}") - - def error(self, error_type: str, msg: str) -> None: - """Send an error message to frontend and log it locally""" - log.warning( - f"websocket error {error_type}: {msg}" - ) - self.send("error", { - "type": error_type, - "msg": msg, - }) - - def onConnect(self, request): - if "libervia-page" not in request.protocols: - raise types.ConnectionDeny( - types.ConnectionDeny.NOT_IMPLEMENTED, "No supported protocol" - ) - self._init_ok = False - cookies = {} - for cookie in request.headers.get("cookie", "").split(";"): - k, __, v = cookie.partition("=") - cookies[k.strip()] = v.strip() - session_uid = ( - cookies.get("TWISTED_SECURE_SESSION") - or cookies.get("TWISTED_SESSION") - or "" - ) - if not session_uid: - raise types.ConnectionDeny( - types.ConnectionDeny.FORBIDDEN, "No session set" - ) - try: - session = host.site.getSession(session_uid.encode()) - except KeyError: - raise types.ConnectionDeny( - types.ConnectionDeny.FORBIDDEN, "Invalid session" - ) - - session.touch() - session_data = session.getComponent(session_iface.IWebSession) - if session_data.ws_socket is not None: - log.warning(f"Session socket is already set {session_data.ws_socket=} {self=}], force closing it") - try: - session_data.ws_socket.send( - "force_close", {"reason": "duplicate connection detected"} - ) - except Exception as e: - log.warning(f"Can't force close old connection: {e}") - session_data.ws_socket = self - self.__session = session_data - self.__profile = session_data.profile or C.SERVICE_PROFILE - log.debug(f"websocket connection connected for profile {self.__profile}") - return "libervia-page" - - def on_open(self): - log.debug("websocket connection opened") - - def onMessage(self, payload: bytes, isBinary: bool) -> None: - if self.__session is None: - raise exceptions.InternalError("empty session, this should never happen") - try: - data_full = json.loads(payload.decode()) - data_type = data_full["type"] - data = data_full["data"] - except ValueError as e: - self.error( - "bad_request", - f"Not valid JSON, ignoring data ({e}): {payload!r}" - ) - return - except KeyError: - self.error( - "bad_request", - 'Invalid request (missing "type" or "data")' - ) - return - - if data_type == "init": - if self._init_ok: - self.error( - "bad_request", - "double init" - ) - self.sendClose(4400, "Bad Request") - return - - try: - profile = data["profile"] or C.SERVICE_PROFILE - token = data["token"] - except KeyError: - self.error( - "bad_request", - "Invalid init data (missing profile or token)" - ) - self.sendClose(4400, "Bad Request") - return - if (( - profile != self.__profile - or (token != self.__session.ws_token and profile != C.SERVICE_PROFILE) - )): - log.debug( - f"profile got {profile}, was expecting {self.__profile}, " - f"token got {token}, was expecting {self.__session.ws_token}, " - ) - self.error( - "Unauthorized", - "Invalid profile or token" - ) - self.sendClose(4401, "Unauthorized") - return - else: - log.debug(f"websocket connection initialized for {profile}") - self._init_ok = True - # we now send all cached data, if any - while True: - try: - session_kw = self.__session.ws_buffer.popleft() - except IndexError: - break - else: - self.send(**session_kw) - - if not self._init_ok: - self.error( - "Unauthorized", - "session not authorized" - ) - self.sendClose(4401, "Unauthorized") - return - - def on_close(self, wasClean, code, reason): - log.debug(f"closing websocket (profile: {self.__profile}, reason: {reason})") - if self.__profile is None: - log.error("self.__profile should not be None") - self.__profile = C.SERVICE_PROFILE - - if self.__session is None: - log.warning("closing a socket without attached session") - elif self.__session.ws_socket != self: - log.error("session socket is not linked to our instance") - else: - log.debug(f"reseting websocket session for {self.__profile}") - self.__session.ws_socket = None - sessions = session_iface.WebSession.get_profile_sessions(self.__profile) - log.debug(f"websocket connection for profile {self.__profile} closed") - self.__profile = None - - @classmethod - def get_base_url(cls, secure): - return "ws{sec}://localhost:{port}".format( - sec="s" if secure else "", - port=host.options["port_https" if secure else "port"], - ) - - @classmethod - def get_resource(cls, secure): - factory = websocket.WebSocketServerFactory(cls.get_base_url(secure)) - factory.protocol = cls - return resource.WebSocketResource(factory)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/VERSION Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,1 @@ +0.9.0D
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,5 @@ +import os.path + +version_file = os.path.join(os.path.dirname(__file__), "VERSION") +with open(version_file) as f: + __version__ = f.read().strip()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/common/constants.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + + +# Libervia: a SAT frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from libervia.frontends.quick_frontend import constants +import os.path + + +class Const(constants.Const): + + # XXX: we don't want to use the APP_VERSION inherited from libervia.backend.core.constants version + # as we use this version to check that there is not a mismatch with backend + APP_VERSION = "0.9.0D" # Please add 'D' at the end for dev versions + LIBERVIA_MAIN_PAGE = "libervia.html" + LIBERVIA_PAGE_START = "/login" + + # REGISTRATION + # XXX: for now libervia forces the creation to lower case + # XXX: Regex patterns must be compatible with both Python and JS + REG_LOGIN_RE = r"^[a-z0-9_-]+$" + REG_EMAIL_RE = r"^.+@.+\..+" + PASSWORD_MIN_LENGTH = 6 + + # HTTP REQUEST RESULT VALUES + PROFILE_AUTH_ERROR = "PROFILE AUTH ERROR" + XMPP_AUTH_ERROR = "XMPP AUTH ERROR" + ALREADY_WAITING = "ALREADY WAITING" + SESSION_ACTIVE = "SESSION ACTIVE" + NOT_CONNECTED = "NOT CONNECTED" + PROFILE_LOGGED = "LOGGED" + PROFILE_LOGGED_EXT_JID = "LOGGED (REGISTERED WITH EXTERNAL JID)" + ALREADY_EXISTS = "ALREADY EXISTS" + INVALID_CERTIFICATE = "INVALID CERTIFICATE" + REGISTRATION_SUCCEED = "REGISTRATION" + INTERNAL_ERROR = "INTERNAL ERROR" + INVALID_INPUT = "INVALID INPUT" + BAD_REQUEST = "BAD REQUEST" + NO_REPLY = "NO REPLY" + NOT_ALLOWED = "NOT ALLOWED" + UPLOAD_OK = "UPLOAD OK" + UPLOAD_KO = "UPLOAD KO" + + # directories + MEDIA_DIR = "media/" + CACHE_DIR = "cache" + + # avatars + DEFAULT_AVATAR_FILE = "default_avatar.png" + DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE) + EMPTY_AVATAR_FILE = "empty_avatar" + EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE) + + # blog + MAM_FILTER_CATEGORY = "http://salut-a-toi.org/protocols/mam_filter_category"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_bridge/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import json +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.web.server.constants import Const as C + + +log = getLogger(__name__) +"""access to restricted bridge""" + +name = "bridge" +on_data_post = "continue" + +# bridge method allowed when no profile is connected +NO_SESSION_ALLOWED = ("contacts_get", "identities_base_get", "identities_get") + + +def parse_url(self, request): + self.get_path_args(request, ["method_name"], min_args=1) + + +async def render(self, request): + if request.method != b'POST': + log.warning(f"Bad method used with _bridge endpoint: {request.method.decode()}") + return self.page_error(request, C.HTTP_BAD_REQUEST) + data = self.get_r_data(request) + profile = self.get_profile(request) + self.check_csrf(request) + method_name = data["method_name"] + if profile is None: + if method_name in NO_SESSION_ALLOWED: + # this method is allowed, we use the service profile + profile = C.SERVICE_PROFILE + else: + log.warning("_bridge endpoint accessed without authorisation") + return self.page_error(request, C.HTTP_UNAUTHORIZED) + method_data = json.load(request.content) + try: + bridge_method = getattr(self.host.restricted_bridge, method_name) + except AttributeError: + log.warning(_( + "{profile!r} is trying to access a bridge method not implemented in " + "RestrictedBridge: {method_name}").format( + profile=profile, method_name=method_name)) + return self.page_error(request, C.HTTP_BAD_REQUEST) + + try: + args, kwargs = method_data['args'], method_data['kwargs'] + except KeyError: + log.warning(_( + "{profile!r} has sent a badly formatted method call: {method_data}" + ).format(profile=profile, method_data=method_data)) + return self.page_error(request, C.HTTP_BAD_REQUEST) + + if "profile" in kwargs or "profile_key" in kwargs: + log.warning(_( + '"profile" key should not be in method kwargs, hack attempt? ' + "profile={profile}, method_data={method_data}" + ).format(profile=profile, method_data=method_data)) + return self.page_error(request, C.HTTP_BAD_REQUEST) + + try: + ret = await bridge_method(*args, **kwargs, profile=profile) + except BridgeException as e: + request.setResponseCode(C.HTTP_PROXY_ERROR) + ret = { + "fullname": e.fullname, + "message": e.message, + "condition": e.condition, + "module": e.module, + "classname": e.classname, + } + return json.dumps(ret)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,5 @@ +import bridge + + +# we create a bridge instance to receive signals +bridge.Bridge()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/alt_media_player.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + +"""This module implement an alternative media player + +If browser can't play natively some libre video/audio formats, ogv.js will be used, +otherwise the native player will be used. + +This player uses its own controls, this allow better tuning/event handling notably with +slideshow. +""" + +from browser import document, timer, html + + +NO_PAGINATION = "NO_PAGINATION" +NO_SCROLLBAR = "NO_SCROLLBAR" + + +class MediaPlayer: + TIMER_MODES = ("timer", "remaining") + # will be set to False if browser can't play natively webm or ogv + native = True + # will be set to True when template and modules will be imported + imports_done = False + + def __init__( + self, + sources, + to_rpl_vid_elt=None, + poster=None, + reduce_click_area=False + ): + """ + @param sources: list of paths to media + only the first one is used at the moment + @param to_rpl_vid_elt: video element to replace + if None, nothing is replaced and element must be inserted manually + @param reduce_click_area: when True, only center of the element will react to + click. Useful when used in slideshow, as click on border is used to + show/hide slide controls + """ + self.do_imports() + + self.reduce_click_area = reduce_click_area + + self.media_player_elt = media_player_elt = media_player_tpl.get_elt() + self.player = player = self._create_player(sources, poster) + if to_rpl_vid_elt is not None: + to_rpl_vid_elt.parentNode.replaceChild(media_player_elt, to_rpl_vid_elt) + overlay_play_elt = self.media_player_elt.select_one(".media_overlay_play") + overlay_play_elt.bind("click", self.on_play_click) + self.progress_elt = media_player_elt.select_one("progress") + self.progress_elt.bind("click", self.on_progress_click) + self.timer_elt = media_player_elt.select_one(".timer") + self.timer_mode = "timer" + + self.controls_elt = media_player_elt.select_one(".media_controls") + # we devnull 2 following events to avoid accidental side effect + # this is notably useful in slideshow to avoid changing the slide when + # the user misses slightly a button + self.controls_elt.bind("mousedown", self._devnull) + self.controls_elt.bind("click", self._devnull) + + player_wrapper_elt = media_player_elt.select_one(".media_elt") + player.preload = "none" + player.src = sources[0] + player_wrapper_elt <= player + self.hide_controls_timer = None + + # we capture mousedown to avoid side effect on slideshow + player_wrapper_elt.addEventListener("mousedown", self._devnull) + player_wrapper_elt.addEventListener("click", self.on_player_click) + + # buttons + for handler in ("play", "change_timer_mode", "change_volume", "fullscreen"): + for elt in media_player_elt.select(f".click_to_{handler}"): + elt.bind("click", getattr(self, f"on_{handler}_click")) + # events + # FIXME: progress is not implemented in OGV.js, update when available + for event in ("play", "pause", "timeupdate", "ended", "volumechange"): + player.bind(event, getattr(self, f"on_{event}")) + + @property + def elt(self): + return self.media_player_elt + + def _create_player(self, sources, poster): + """Create player element, using native one when possible""" + player = None + if not self.native: + source = sources[0] + ext = self.get_source_ext(source) + if ext is None: + print( + f"no extension found for {source}, using native player" + ) + elif ext in self.cant_play_ext_list: + print(f"OGV player user for {source}") + player = self.ogv.OGVPlayer.new() + # OGCPlayer has non standard "poster" property + player.poster = poster + if player is None: + player = html.VIDEO(poster=poster) + return player + + def reset(self): + """Put back media player in intial state + + media will be stopped, time will be set to beginning, overlay will be put back + """ + print("resetting media player") + self.player.pause() + self.player.currentTime = 0 + self.media_player_elt.classList.remove("in_use") + + def _devnull(self, evt): + # stop an event + evt.preventDefault() + evt.stopPropagation() + + def on_player_click(self, evt): + if self.reduce_click_area: + bounding_rect = self.media_player_elt.getBoundingClientRect() + margin_x = margin_y = 200 + if ((evt.clientX - bounding_rect.left < margin_x + or bounding_rect.right - evt.clientX < margin_x + or evt.clientY - bounding_rect.top < margin_y + or bounding_rect.bottom - evt.clientY < margin_y + )): + # click is not in the center, we don't handle it and let the event + # propagate + return + self.on_play_click(evt) + + def on_play_click(self, evt): + evt.preventDefault() + evt.stopPropagation() + self.media_player_elt.classList.add("in_use") + if self.player.paused: + print("playing") + self.player.play() + else: + self.player.pause() + print("paused") + + def on_change_timer_mode_click(self, evt): + evt.preventDefault() + evt.stopPropagation() + self.timer_mode = self.TIMER_MODES[ + (self.TIMER_MODES.index(self.timer_mode) + 1) % len(self.TIMER_MODES) + ] + + def on_change_volume_click(self, evt): + evt.stopPropagation() + self.player.muted = not self.player.muted + + def on_fullscreen_click(self, evt): + evt.stopPropagation() + try: + fullscreen_elt = document.fullscreenElement + request_fullscreen = self.media_player_elt.requestFullscreen + except AttributeError: + print("fullscreen is not available on this browser") + else: + if fullscreen_elt == None: + print("requesting fullscreen") + request_fullscreen() + else: + print(f"leaving fullscreen: {fullscreen_elt}") + try: + document.exitFullscreen() + except AttributeError: + print("exitFullscreen not available on this browser") + + def on_progress_click(self, evt): + evt.stopPropagation() + position = evt.offsetX / evt.target.width + new_time = self.player.duration * position + self.player.currentTime = new_time + + def on_play(self, evt): + self.media_player_elt.classList.add("playing") + self.show_controls() + self.media_player_elt.bind("mousemove", self.on_mouse_move) + + def on_pause(self, evt): + self.media_player_elt.classList.remove("playing") + self.show_controls() + self.media_player_elt.unbind("mousemove") + + def on_timeupdate(self, evt): + self.update_progress() + + def on_ended(self, evt): + self.update_progress() + + def on_volumechange(self, evt): + evt.stopPropagation() + if self.player.muted: + self.media_player_elt.classList.add("muted") + else: + self.media_player_elt.classList.remove("muted") + + def on_mouse_move(self, evt): + self.show_controls() + + def update_progress(self): + duration = self.player.duration + current_time = duration if self.player.ended else self.player.currentTime + self.progress_elt.max = duration + self.progress_elt.value = current_time + self.progress_elt.text = f"{current_time/duration*100:.02f}" + current_time, duration = int(current_time), int(duration) + if self.timer_mode == "timer": + cur_min, cur_sec = divmod(current_time, 60) + tot_min, tot_sec = divmod(duration, 60) + self.timer_elt.text = f"{cur_min}:{cur_sec:02d}/{tot_min}:{tot_sec:02d}" + elif self.timer_mode == "remaining": + rem_min, rem_sec = divmod(duration - current_time, 60) + self.timer_elt.text = f"{rem_min}:{rem_sec:02d}" + else: + print(f"ERROR: unknown timer mode: {self.timer_mode}") + + def hide_controls(self): + self.controls_elt.classList.add("hidden") + self.media_player_elt.style.cursor = "none" + if self.hide_controls_timer is not None: + timer.clear_timeout(self.hide_controls_timer) + self.hide_controls_timer = None + + def show_controls(self): + self.controls_elt.classList.remove("hidden") + self.media_player_elt.style.cursor = "" + if self.hide_controls_timer is not None: + timer.clear_timeout(self.hide_controls_timer) + if self.player.paused: + self.hide_controls_timer = None + else: + self.hide_controls_timer = timer.set_timeout(self.hide_controls, 3000) + + @classmethod + def do_imports(cls): + # we do imports (notably for ogv.js) only if they are necessary + if cls.imports_done: + return + if not cls.native: + from js_modules import ogv + cls.ogv = ogv + if not ogv.OGVCompat.supported('OGVPlayer'): + print("Can't use OGVPlayer with this browser") + raise NotImplementedError + import template + global media_player_tpl + media_player_tpl = template.Template("components/media_player.html") + cls.imports_done = True + + @staticmethod + def get_source_ext(source): + try: + ext = f".{source.rsplit('.', 1)[1].strip()}" + except IndexError: + return None + return ext or None + + @classmethod + def install(cls, cant_play): + cls.native = False + ext_list = set() + for data in cant_play.values(): + ext_list.update(data['ext']) + cls.cant_play_ext_list = ext_list + for to_rpl_vid_elt in document.body.select('video'): + sources = [] + src = (to_rpl_vid_elt.src or '').strip() + if src: + sources.append(src) + + for source_elt in to_rpl_vid_elt.select('source'): + src = (source_elt.src or '').strip() + if src: + sources.append(src) + + # FIXME: we only use first found source + try: + source = sources[0] + except IndexError: + print(f"Can't find any source for following elt:\n{to_rpl_vid_elt.html}") + continue + + ext = cls.get_source_ext(source) + + ext = f".{source.rsplit('.', 1)[1]}" + if ext is None: + print( + "No extension found for source of following elt:\n" + f"{to_rpl_vid_elt.html}" + ) + continue + if ext in ext_list: + print(f"alternative player will be used for {source!r}") + cls(sources, to_rpl_vid_elt) + + +def install_if_needed(): + CONTENT_TYPES = { + "ogg_theora": {"type": 'video/ogg; codecs="theora"', "ext": [".ogv", ".ogg"]}, + "webm_vp8": {"type": 'video/webm; codecs="vp8, vorbis"', "ext": [".webm"]}, + "webm_vp9": {"type": 'video/webm; codecs="vp9"', "ext": [".webm"]}, + # FIXME: handle audio + # "ogg_vorbis": {"type": 'audio/ogg; codecs="vorbis"', "ext": ".ogg"}, + } + test_media_elt = html.VIDEO() + cant_play = {k:d for k,d in CONTENT_TYPES.items() + if test_media_elt.canPlayType(d['type']) != "probably"} + + if cant_play: + cant_play_list = '\n'.join(f"- {k} ({d['type']})" for k, d in cant_play.items()) + print( + "This browser is incompatible with following content types, using " + f"alternative:\n{cant_play_list}" + ) + try: + MediaPlayer.install(cant_play) + except NotImplementedError: + pass + else: + print("This browser can play natively all requested open video/audio formats")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/bridge.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,348 @@ +from browser import window, aio, timer, console as log +import time +import random +import json +import dialog +import javascript + + +log.warning = log.warn +tab_id = random.randint(0, 2**64) +log.info(f"TAB ID is {tab_id}") + + +class BridgeException(Exception): + """An exception which has been raised from the backend and arrived to the frontend.""" + + def __init__(self, name, message="", condition=""): + """ + + @param name (str): full exception class name (with module) + @param message (str): error message + @param condition (str) : error condition + """ + Exception.__init__(self) + self.fullname = str(name) + self.message = str(message) + self.condition = str(condition) if condition else "" + self.module, __, self.classname = str(self.fullname).rpartition(".") + + def __str__(self): + return f"{self.classname}: {self.message or ''}" + + def __eq__(self, other): + return self.classname == other + + +class WebSocket: + + def __init__(self, broadcast_channel): + self.broadcast_channel = broadcast_channel + self.token = window.ws_token + self.create_socket() + self.retrying = False + self.network_error = False + + @property + def profile(self): + return self.broadcast_channel.profile + + def retry_connect(self) -> None: + if self.retrying: + return + self.retrying = True + try: + notif = dialog.RetryNotification(self.create_socket) + notif.show( + "Can't connect to server", + delay=random.randint(0, 30) + ) + except Exception as e: + # for security reasons, browser don't give the reason of the error with + # WebSockets, thus we try to detect network error here, as if we can't show + # the retry dialog, that probably means that it's not reachable + try: + name = e.name + except AttributeError: + name = None + if name == "NetworkError": + self.network_error = True + log.warning("network error detected, server may be down") + log.error(f"Can't show retry dialog: {e}") + log.info("retrying in 30s") + timer.set_timeout(self.create_socket, 30000) + else: + raise e + else: + # if we can show the retry dialog, the network is fine + self.network_error = False + + def create_socket(self) -> None: + log.debug("creating socket") + self.retrying = False + self.socket = window.WebSocket.new(window.ws_url, "libervia-page") + self.socket_start = time.time() + self.socket.bind("open", self.on_open) + self.socket.bind("error", self.on_error) + self.socket.bind("close", self.on_close) + self.socket.bind("message", self.on_message) + + def send(self, data_type: str, data: dict) -> None: + self.socket.send(json.dumps({ + "type": data_type, + "data": data + })) + + def close(self) -> None: + log.debug("closing socket") + self.broadcast_channel.ws = None + self.socket.close() + + def on_open(self, evt) -> None: + log.info("websocket connection opened") + self.send("init", {"profile": self.profile, "token": self.token}) + + def on_error(self, evt) -> None: + if not self.network_error and time.time() - self.socket_start < 5: + # disconnection is happening fast, we try to reload + log.warning("Reloading due to suspected session error") + window.location.reload() + else: + self.retry_connect() + + def on_close(self, evt) -> None: + log.warning(f"websocket is closed {evt.code=} {evt.reason=}") + if self.broadcast_channel.ws is None: + # this is a close requested locally + return + elif evt.code == 4401: + log.info( + "no authorized, the session is probably not valid anymore, reloading" + ) + window.location.reload() + else: + # close event may be due to normal tab closing, thus we try to reconnect only + # after a delay + timer.set_timeout(self.retry_connect, 5000) + + def on_message(self, message_evt): + msg_data = json.loads(message_evt.data) + msg_type = msg_data.get("type") + if msg_type == "bridge": + log.debug( + f"==> bridge message: {msg_data=}" + ) + self.broadcast_channel.post( + msg_type, + msg_data["data"] + ) + elif msg_type == "force_close": + log.warning(f"force closing connection: {msg_data.get('reason')}") + self.close() + else: + dialog.notification.show( + f"Unexpected message type {msg_type}" + "error" + ) + + +class BroadcastChannel: + handlers = {} + + def __init__(self): + log.debug(f"BroadcastChannel init with profile {self.profile!r}") + self.start = time.time() + self.bc = window.BroadcastChannel.new("libervia") + self.bc.bind("message", self.on_message) + # there is no way to check if there is already a connection in BroadcastChannel + # API, thus we wait a bit to see if somebody is answering. If not, we are probably + # the first tab. + self.check_connection_timer = timer.set_timeout(self.establish_connection, 20) + self.ws = None + # set of all known tab ids + self.tabs_ids = {tab_id} + self.post("salut_a_vous", { + "id": tab_id, + "profile": self.profile + }) + window.bind("unload", self.on_unload) + + @property + def profile(self): + return window.profile or "" + + @property + def connecting_tab(self) -> bool: + """True is this tab is the one establishing the websocket connection""" + return self.ws is not None + + @connecting_tab.setter + def connecting_tab(self, connecting: bool) -> None: + if connecting: + if self.ws is None: + self.ws = WebSocket(self) + self.post("connection", { + "tab_id": tab_id + }) + elif self.ws is not None: + self.ws.close() + + def establish_connection(self) -> None: + """Called when there is no existing connection""" + timer.clear_timeout(self.check_connection_timer) + log.debug(f"Establishing connection {tab_id=}") + self.connecting_tab = True + + def handle_bridge_signal(self, data: dict) -> None: + """Forward bridge signals to registered handlers""" + signal = data["signal"] + handlers = self.handlers.get(signal, []) + for handler in handlers: + handler(*data["args"]) + + def on_message(self, evt) -> None: + data = json.loads(evt.data) + if data["type"] == "bridge": + self.handle_bridge_signal(data) + elif data["type"] == "salut_a_toi": + # this is a response from existing tabs + other_tab_id = data["id"] + if other_tab_id == tab_id: + # in the unlikely case that this happens, we simply reload this tab to get + # a new ID + log.warning("duplicate tab id, we reload the page: {tab_id=}") + window.location.reload() + return + self.tabs_ids.add(other_tab_id) + if data["connecting_tab"] and self.check_connection_timer is not None: + # this tab has the websocket connection to server + log.info(f"there is already a connection to server at tab {other_tab_id}") + timer.clear_timeout(self.check_connection_timer) + self.check_connection_timer = None + elif data["type"] == "salut_a_vous": + # a new tab has just been created + if data["profile"] != self.profile: + log.info( + f"we are now connected with the profile {data['profile']}, " + "reloading the page" + ) + window.location.reload() + else: + self.tabs_ids.add(data["id"]) + self.post("salut_a_toi", { + "id": tab_id, + "connecting_tab": self.connecting_tab + }) + elif data["type"] == "connection": + log.info(f"tab {data['id']} is the new connecting tab") + elif data["type"] == "salut_a_rantanplan": + # a tab is being closed + other_tab_id = data["id"] + # it is unlikely that there is a collision, but just in case we check it + if other_tab_id != tab_id: + self.tabs_ids.discard(other_tab_id) + if data["connecting_tab"]: + log.info(f"connecting tab with id {other_tab_id} has been closed") + if max(self.tabs_ids) == tab_id: + log.info("this is the new connecting tab, establish_connection") + self.connecting_tab = True + else: + log.info(f"tab with id {other_tab_id} has been closed") + else: + log.warning(f"unknown message type: {data}") + + def post(self, data_type, data: dict): + data["type"] = data_type + data["id"] = tab_id + self.bc.postMessage(json.dumps(data)) + if data_type == "bridge": + self.handle_bridge_signal(data) + + def on_unload(self, evt) -> None: + """Send a message to indicate that the tab is being closed""" + self.post("salut_a_rantanplan", { + "id": tab_id, + "connecting_tab": self.connecting_tab + }) + + +class Bridge: + bc: BroadcastChannel | None = None + + def __init__(self) -> None: + if Bridge.bc is None: + Bridge.bc = BroadcastChannel() + + def __getattr__(self, attr): + return lambda *args, **kwargs: self.call(attr, *args, **kwargs) + + def on_load(self, xhr, ev, callback, errback): + if xhr.status == 200: + ret = javascript.JSON.parse(xhr.response) + if callback is not None: + if ret is None: + callback() + else: + callback(ret) + elif xhr.status == 502: + # PROXY_ERROR is used for bridge error + ret = javascript.JSON.parse(xhr.response) + if errback is not None: + errback(ret) + else: + log.error( + f"bridge call failed: code: {xhr.response}, text: {xhr.statusText}" + ) + if errback is not None: + errback({"fullname": "BridgeInternalError", "message": xhr.statusText}) + + def call(self, method_name, *args, callback, errback, **kwargs): + xhr = window.XMLHttpRequest.new() + xhr.bind('load', lambda ev: self.on_load(xhr, ev, callback, errback)) + xhr.bind('error', lambda ev: errback( + {"fullname": "ConnectionError", "message": xhr.statusText})) + xhr.open("POST", f"/_bridge/{method_name}", True) + data = javascript.JSON.stringify({ + "args": args, + "kwargs": kwargs, + }) + xhr.setRequestHeader('X-Csrf-Token', window.csrf_token) + xhr.send(data) + + def register_signal(self, signal: str, handler, iface=None) -> None: + BroadcastChannel.handlers.setdefault(signal, []).append(handler) + log.debug(f"signal {signal} has been registered") + + +class AsyncBridge: + + def __getattr__(self, attr): + return lambda *args, **kwargs: self.call(attr, *args, **kwargs) + + async def call(self, method_name, *args, **kwargs): + print(f"calling {method_name}") + data = javascript.JSON.stringify({ + "args": args, + "kwargs": kwargs, + }) + url = f"/_bridge/{method_name}" + r = await aio.post( + url, + headers={ + 'X-Csrf-Token': window.csrf_token, + }, + data=data, + ) + + if r.status == 200: + return javascript.JSON.parse(r.data) + elif r.status == 502: + ret = javascript.JSON.parse(r.data) + raise BridgeException(ret['fullname'], ret['message'], ret['condition']) + else: + print(f"bridge called failed: code: {r.status}, text: {r.statusText}") + raise BridgeException("InternalError", r.statusText) + + def register_signal(self, signal: str, handler, iface=None) -> None: + BroadcastChannel.handlers.setdefault(signal, []).append(handler) + log.debug(f"signal {signal} has been registered")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/browser_meta.json Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,25 @@ +{ + "js": { + "package": { + "dependencies": { + "nunjucks": "^3.2.3", + "swiper": "^6.8.4", + "moment": "^2.29.1", + "ogv": "^1.8.4" + } + }, + "brython_map": { + "nunjucks": "nunjucks/browser/nunjucks.min.js", + "swiper": { + "path": "swiper/swiper-bundle.min.js", + "export": ["Swiper"] + }, + "moment": "moment/min/moment.min.js", + "ogv": { + "path": "ogv/dist/ogv.js", + "export": ["OGVCompat", "OGVLoader", "OGVMediaError", "OGVMediaType", "OGVTimeRanges", "OGVPlayer", "OGVVersion"], + "extra_init": "OGVLoader.base='/{build_dir}/node_modules/ogv/dist'" + } + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/cache.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,156 @@ +from browser import window +from browser.local_storage import storage +from javascript import JSON +from dialog import notification +from bridge import Bridge + +session_uuid = window.session_uuid +bridge = Bridge() + +# XXX: we don't use browser.object_storage because it is affected by +# https://github.com/brython-dev/brython/issues/1467 and mixing local_storage.storage +# and object_storage was resulting in weird behaviour (keys found in one not in the +# other) + + +class Cache: + + def __init__(self): + try: + cache = storage['libervia_cache'] + except KeyError: + self.request_data_from_backend() + else: + cache = JSON.parse(cache) + if cache['metadata']['session_uuid'] != session_uuid: + print("data in cache are not valid for this session, resetting") + del storage['libervia_cache'] + self.request_data_from_backend() + else: + self._cache = cache + print("storage cache is used") + + @property + def roster(self): + return self._cache['roster'] + + @property + def identities(self): + return self._cache['identities'] + + def update(self): + # FIXME: we use window.JSON as a workaround to + # https://github.com/brython-dev/brython/issues/1467 + print(f"updating: {self._cache}") + storage['libervia_cache'] = window.JSON.stringify(self._cache) + print("cache stored") + + def _store_if_complete(self): + self._completed_count -= 1 + if self._completed_count == 0: + del self._completed_count + self.update() + + def get_contacts_cb(self, contacts): + print("roster received") + roster = self._cache['roster'] + for contact_jid, attributes, groups in contacts: + roster[contact_jid] = { + 'attributes': attributes, + 'groups': groups, + } + self._store_if_complete() + + def identities_base_get_cb(self, identities_raw): + print("base identities received") + identities = JSON.parse(identities_raw) + self._cache['identities'].update(identities) + self._store_if_complete() + + def request_failed(self, exc, message): + notification.show(message.format(exc=exc), "error") + self._store_if_complete() + + def request_data_from_backend(self): + self._cache = { + 'metadata': { + "session_uuid": session_uuid, + }, + 'roster': {}, + 'identities': {}, + } + self._completed_count = 2 + print("requesting roster to backend") + bridge.contacts_get( + callback=self.get_contacts_cb, + errback=lambda e: self.request_failed(e, "Can't get contacts: {exc}") + ) + print("requesting base identities to backend") + bridge.identities_base_get( + callback=self.identities_base_get_cb, + errback=lambda e: self.request_failed(e, "Can't get base identities: {exc}") + ) + + def _fill_identities_cb(self, new_identities_raw, callback): + new_identities = JSON.parse(new_identities_raw) + print(f"new identities: {new_identities.keys()}") + self._cache['identities'].update(new_identities) + self.update() + if callback: + callback() + + def fill_identities(self, entities, callback=None): + """Check that identities for entities exist, request them otherwise""" + to_get = {e for e in entities if e not in self._cache['identities']} + if to_get: + bridge.identities_get( + list(to_get), + ['avatar', 'nicknames'], + callback=lambda identities: self._fill_identities_cb( + identities, callback), + errback=lambda failure_: notification.show( + f"Can't get identities: {failure_}", + "error" + ) + ) + else: + # we already have all identities + print("no missing identity") + if callback: + callback() + + def match_identity(self, entity_jid, text, identity=None): + """Returns True if a text match an entity identity + + identity will be matching if its jid or any of its name contain text + @param entity_jid: jid of the entity to check + @param text: text to use for filtering. Must be in lowercase and stripped + @param identity: identity data + if None, it will be retrieved if jid is not matching + @return: True if entity is matching + """ + if text in entity_jid: + return True + if identity is None: + try: + identity = self.identities[entity_jid] + except KeyError: + print(f"missing identity: {entity_jid}") + return False + return any(text in n.lower() for n in identity['nicknames']) + + def matching_identities(self, text): + """Return identities corresponding to a text + + """ + text = text.lower().strip() + for entity_jid, identity in self._cache['identities'].items(): + if ((text in entity_jid + or any(text in n.lower() for n in identity['nicknames']) + )): + yield entity_jid + + +cache = Cache() +roster = cache.roster +identities = cache.identities
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/dialog.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,131 @@ +"""manage common dialogs""" + +from browser import document, window, timer, console as log +from template import Template + +log.warning = log.warn + + +class Confirm: + + def __init__(self, message, ok_label="", cancel_label="", ok_color="success"): + self._tpl = Template("dialogs/confirm.html") + self.message = message + self.ok_label = ok_label + assert ok_color in ("success", "danger") + self.ok_color = ok_color + self.cancel_label = cancel_label + + def cancel_cb(self, evt, notif_elt): + notif_elt.remove() + + def show(self, ok_cb, cancel_cb=None): + if cancel_cb is None: + cancel_cb = self.cancel_cb + notif_elt = self._tpl.get_elt({ + "message": self.message, + "ok_label": self.ok_label, + "ok_color": self.ok_color, + "cancel_label": self.cancel_label, + }) + + document['notifs_area'] <= notif_elt + timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) + for cancel_elt in notif_elt.select(".click_to_cancel"): + cancel_elt.bind("click", lambda evt: cancel_cb(evt, notif_elt)) + for cancel_elt in notif_elt.select(".click_to_ok"): + cancel_elt.bind("click", lambda evt: ok_cb(evt, notif_elt)) + + def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed): + evt.stopPropagation() + notif_elt.remove() + resolve_cb(confirmed) + + async def ashow(self): + return window.Promise.new( + lambda resolve_cb, reject_cb: + self.show( + lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True), + lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False) + ) + ) + + +class Notification: + + def __init__(self): + self._tpl = Template("dialogs/notification.html") + + def close(self, notif_elt): + notif_elt.classList.remove('state_appended') + notif_elt.bind("transitionend", lambda __: notif_elt.remove()) + + def show( + self, + message: str, + level: str = "info", + delay: int = 5 + ) -> None: + # we log in console error messages, may be useful + if level in ("warning", "error"): + print(f"[{level}] {message}") + notif_elt = self._tpl.get_elt({ + "message": message, + "level": level, + }) + document["notifs_area"] <= notif_elt + timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) + timer.set_timeout(lambda: self.close(notif_elt), delay * 1000) + for elt in notif_elt.select('.click_to_close'): + elt.bind('click', lambda __: self.close(notif_elt)) + + +class RetryNotification: + def __init__(self, retry_cb): + self._tpl = Template("dialogs/retry-notification.html") + self.retry_cb = retry_cb + self.counter = 0 + self.timer = None + + def retry(self, notif_elt): + if self.timer is not None: + timer.clear_interval(self.timer) + self.timer = None + notif_elt.classList.remove('state_appended') + notif_elt.bind("transitionend", lambda __: notif_elt.remove()) + self.retry_cb() + + def update_counter(self, notif_elt): + counter = notif_elt.select_one(".retry_counter") + counter.text = str(self.counter) + self.counter -= 1 + if self.counter < 0: + self.retry(notif_elt) + + def show( + self, + message: str, + level: str = "warning", + delay: int = 5 + ) -> None: + # we log in console error messages, may be useful + if level == "error": + log.error(message) + elif level == "warning": + log.warning(message) + self.counter = delay + notif_elt = self._tpl.get_elt({ + "message": message, + "level": level, + }) + self.update_counter(notif_elt) + document["notifs_area"] <= notif_elt + timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0) + self.timer = timer.set_interval(self.update_counter, 1000, notif_elt) + for elt in notif_elt.select('.click_to_retry'): + elt.bind('click', lambda __: self.retry(notif_elt)) + + + + +notification = Notification()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/editor.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,234 @@ +"""text edition management""" + +from browser import document, window, aio, bind, timer +from browser.local_storage import storage +from browser.object_storage import ObjectStorage +from javascript import JSON +from bridge import AsyncBridge as Bridge, BridgeException +from template import Template +import dialog + +bridge = Bridge() +object_storage = ObjectStorage(storage) +profile = window.profile + +# how often we save forms, in seconds +AUTOSAVE_FREQUENCY = 20 + + +def serialise_form(form_elt): + ret = {} + for elt in form_elt.elements: + if elt.tagName == "INPUT": + if elt.type in ("hidden", "submit"): + continue + elif elt.type == "text": + ret[elt.name] = elt.value + else: + print(f"elt.type not managet yet: {elt.type}") + continue + elif elt.tagName == "TEXTAREA": + ret[elt.name] = elt.value + elif elt.tagName in ("BUTTON",): + continue + else: + print(f"tag not managet yet: {elt.tagName}") + continue + return ret + + +def restore_form(form_elt, data): + for elt in form_elt.elements: + if elt.tagName not in ("INPUT", "TEXTAREA"): + continue + try: + value = data[elt.name] + except KeyError: + continue + else: + elt.value = value + + +def set_form_autosave(form_id): + """Save locally form data regularly and restore it until it's submitted + + form is saved every AUTOSAVE_FREQUENCY seconds and when visibility is lost. + Saved data is restored when the method is called. + Saved data is cleared when the form is submitted. + """ + if profile is None: + print(f"No session started, won't save and restore form {form_id}") + return + + form_elt = document[form_id] + submitted = False + + key = {"profile": profile, "type": "form_autosave", "form": form_id} + try: + form_saved_data = object_storage[key] + except KeyError: + last_serialised = None + else: + print(f"restoring content of form {form_id!r}") + last_serialised = form_saved_data + restore_form(form_elt, form_saved_data) + + def save_form(): + if not submitted: + nonlocal last_serialised + serialised = serialise_form(form_elt) + if serialised != last_serialised: + last_serialised = serialised + print(f"saving content of form {form_id!r}") + object_storage[key] = serialised + + @bind(form_elt, "submit") + def on_submit(evt): + nonlocal submitted + submitted = True + print(f"clearing stored content of form {form_id!r}") + try: + del object_storage[key] + except KeyError: + print("key error") + pass + + @bind(document, "visibilitychange") + def on_visibiliy_change(evt): + print("visibility change") + if document.visibilityState != "visible": + save_form() + + timer.set_interval(save_form, AUTOSAVE_FREQUENCY * 1000) + + +class TagsEditor: + + def __init__(self, input_selector): + print("installing Tags Editor") + self.input_elt = document.select_one(input_selector) + self.input_elt.style.display = "none" + tags_editor_tpl = Template('editor/tags_editor.html') + self.tag_tpl = Template('editor/tag.html') + + editor_elt = tags_editor_tpl.get_elt() + self.input_elt.parent <= editor_elt + self.tag_input_elt = editor_elt.select_one(".tag_input") + self.tag_input_elt.bind("keydown", self.on_key_down) + self._current_tags = None + self.tags_map = {} + for tag in self.current_tags: + self.add_tag(tag, force=True) + + @property + def current_tags(self): + if self._current_tags is None: + self._current_tags = { + t.strip() for t in self.input_elt.value.split(',') if t.strip() + } + return self._current_tags + + @current_tags.setter + def current_tags(self, tags): + self._current_tags = tags + + def add_tag(self, tag, force=False): + tag = tag.strip() + if not force and (not tag or tag in self.current_tags): + return + self.current_tags = self.current_tags | {tag} + self.input_elt.value = ','.join(self.current_tags) + tag_elt = self.tag_tpl.get_elt({"label": tag}) + self.tags_map[tag] = tag_elt + self.tag_input_elt.parent.insertBefore(tag_elt, self.tag_input_elt) + tag_elt.select_one(".click_to_delete").bind( + "click", lambda evt: self.on_tag_click(evt, tag) + ) + + def remove_tag(self, tag): + try: + tag_elt = self.tags_map[tag] + except KeyError: + print(f"trying to remove an inexistant tag: {tag}") + else: + self.current_tags = self.current_tags - {tag} + self.input_elt.value = ','.join(self.current_tags) + tag_elt.remove() + + def on_tag_click(self, evt, tag): + evt.stopPropagation() + self.remove_tag(tag) + + def on_key_down(self, evt): + if evt.key in (",", "Enter"): + evt.stopPropagation() + evt.preventDefault() + self.add_tag(self.tag_input_elt.value) + self.tag_input_elt.value = "" + + +class BlogEditor: + """Editor class, handling tabs, preview, and submit loading button + + It's using and HTML form as source + The form must have: + - a "title" text input + - a "body" textarea + - an optional "tags" text input with comma separated tags (may be using Tags + Editor) + - a "tab_preview" tab element + """ + + def __init__(self, form_id="blog_post_edit"): + self.tab_select = window.tab_select + self.item_tpl = Template('blog/item.html') + self.form = document[form_id] + for elt in document.select(".click_to_edit"): + elt.bind("click", self.on_edit) + for elt in document.select('.click_to_preview'): + elt.bind("click", lambda evt: aio.run(self.on_preview(evt))) + self.form.bind("submit", self.on_submit) + + + def on_edit(self, evt): + self.tab_select(evt.target, "tab_edit", "is-active") + + async def on_preview(self, evt): + """Generate a blog preview from HTML form + + """ + print("on preview OK") + elt = evt.target + tab_preview = document["tab_preview"] + tab_preview.clear() + data = { + "content_rich": self.form.select_one('textarea[name="body"]').value.strip() + } + title = self.form.select_one('input[name="title"]').value.strip() + if title: + data["title_rich"] = title + tags_input_elt = self.form.select_one('input[name="tags"]') + if tags_input_elt is not None: + tags = tags_input_elt.value.strip() + if tags: + data['tags'] = [t.strip() for t in tags.split(',') if t.strip()] + try: + preview_data = JSON.parse( + await bridge.mb_preview("", "", JSON.stringify(data)) + ) + except BridgeException as e: + dialog.notification.show( + f"Can't generate item preview: {e.message}", + level="error" + ) + else: + self.tab_select(elt, "tab_preview", "is-active") + item_elt = self.item_tpl.get_elt({ + "item": preview_data, + "dates_format": "short", + }) + tab_preview <= item_elt + + def on_submit(self, evt): + submit_btn = document.select_one("button[type='submit']") + submit_btn.classList.add("is-loading")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/errors.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,2 @@ +class TimeoutError(Exception): + """An action has not been done in time"""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/invitation.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,444 @@ +from browser import document, window, timer +from bridge import Bridge +from template import Template +import dialog +from cache import cache +import javascript + +bridge = Bridge() +# we use JS RegExp because Python's re is really long to import in Brython +# FIXME: this is a naive JID regex, a more accurate should be used instead +jid_re = javascript.RegExp.new(r"^\w+@\w+\.\w+") + + +class InvitationManager: + + def __init__(self, invitation_type, invitation_data): + self.invitation_type = invitation_type + self.invitation_data = invitation_data + manager_panel_tpl = Template('invitation/manager.html') + self.manager_panel_elt = manager_panel_tpl.get_elt() + self.invite_by_email_tpl = Template('invitation/invite_by_email.html') + self.affiliation_tpl = Template('invitation/affiliation_item.html') + self.new_item_tpl = Template('invitation/new_item.html') + # list of item passing filter when adding a new contact + self._filtered_new_items = {} + self._active_new_item = None + self._idx = 0 + + def attach(self, affiliations=None): + if affiliations is None: + affiliations = {} + self.affiliations = affiliations + self.side_panel = self.manager_panel_elt.select_one( + '.invitation_manager_side_panel') + self.open() + for close_elt in self.manager_panel_elt.select('.click_to_close'): + close_elt.bind("click", self.on_manager_close) + self.side_panel.bind("click", lambda evt: evt.stopPropagation()) + + cache.fill_identities(affiliations.keys(), callback=self._set_affiliations) + + contact_elt = self.manager_panel_elt.select_one('input[name="contact"]') + contact_elt.bind("input", self.on_contact_input) + contact_elt.bind("keydown", self.on_contact_keydown) + contact_elt.bind("focus", self.on_contact_focus) + contact_elt.bind("blur", self.on_contact_blur) + document['invite_email'].bind('click', self.on_invite_email_click) + + def _set_affiliations(self): + for entity_jid, affiliation in self.affiliations.items(): + self.set_affiliation(entity_jid, affiliation) + + def open(self): + """Re-attach and show a closed panel""" + self._body_ori_style = document.body.style.height, document.body.style.overflow + document.body.style.height = '100vh' + document.body.style.overflow = 'hidden' + document.body <= self.manager_panel_elt + timer.set_timeout(lambda: self.side_panel.classList.add("open"), 0) + + def _on_close_transition_end(self, evt): + self.manager_panel_elt.remove() + # FIXME: not working with Brython, to report upstream + # self.side_panel.unbind("transitionend", self._on_close_transition_end) + self.side_panel.unbind("transitionend") + + def close(self): + """Hide the panel""" + document.body.style.height, document.body.style.overflow = self._body_ori_style + self.side_panel.classList.remove('open') + self.side_panel.bind("transitionend", self._on_close_transition_end) + + def _invite_jid(self, entity_jid, callback, errback=None): + if errback is None: + errback = lambda e: dialog.notification.show(f"invitation failed: {e}", "error") + if self.invitation_type == 'photos': + service = self.invitation_data["service"] + path = self.invitation_data["path"] + album_name = path.rsplit('/')[-1] + print(f"inviting {entity_jid}") + bridge.fis_invite( + entity_jid, + service, + "photos", + "", + path, + album_name, + '', + callback=callback, + errback=errback + ) + elif self.invitation_type == 'pubsub': + service = self.invitation_data["service"] + node = self.invitation_data["node"] + name = self.invitation_data.get("name") + namespace = self.invitation_data.get("namespace") + extra = {} + if namespace: + extra["namespace"] = namespace + print(f"inviting {entity_jid}") + bridge.ps_invite( + entity_jid, + service, + node, + '', + name, + javascript.JSON.stringify(extra), + callback=callback, + errback=errback + ) + else: + print(f"error: unknown invitation type: {self.invitation_type}") + + def invite_by_jid(self, entity_jid): + self._invite_jid( + entity_jid, + callback=lambda entity_jid=entity_jid: self._on_jid_invitation_success(entity_jid), + ) + + def on_manager_close(self, evt): + self.close() + + def _on_jid_invitation_success(self, entity_jid): + form_elt = document['invitation_form'] + contact_elt = form_elt.select_one('input[name="contact"]') + contact_elt.value = "" + contact_elt.dispatchEvent(window.Event.new('input')) + dialog.notification.show( + f"{entity_jid} has been invited", + level="success", + ) + if entity_jid not in self.affiliations: + self.set_affiliation(entity_jid, "member") + + def on_contact_invite(self, evt, entity_jid): + """User is adding a contact""" + form_elt = document['invitation_form'] + contact_elt = form_elt.select_one('input[name="contact"]') + contact_elt.value = "" + contact_elt.dispatchEvent(window.Event.new('input')) + self.invite_by_jid(entity_jid) + + def on_contact_keydown(self, evt): + if evt.key == "Escape": + evt.target.value = "" + evt.target.dispatchEvent(window.Event.new('input')) + elif evt.key == "ArrowDown": + evt.stopPropagation() + evt.preventDefault() + content_elt = document['invitation_contact_search'].select_one( + ".search_dialog__content") + if self._active_new_item == None: + self._active_new_item = content_elt.firstElementChild + self._active_new_item.classList.add('selected') + else: + next_item = self._active_new_item.nextElementSibling + if next_item is not None: + self._active_new_item.classList.remove('selected') + self._active_new_item = next_item + self._active_new_item.classList.add('selected') + elif evt.key == "ArrowUp": + evt.stopPropagation() + evt.preventDefault() + content_elt = document['invitation_contact_search'].select_one( + ".search_dialog__content") + if self._active_new_item == None: + self._active_new_item = content_elt.lastElementChild + self._active_new_item.classList.add('selected') + else: + previous_item = self._active_new_item.previousElementSibling + if previous_item is not None: + self._active_new_item.classList.remove('selected') + self._active_new_item = previous_item + self._active_new_item.classList.add('selected') + elif evt.key == "Enter": + evt.stopPropagation() + evt.preventDefault() + if self._active_new_item is not None: + entity_jid = self._active_new_item.dataset.entityJid + self.invite_by_jid(entity_jid) + else: + if jid_re.exec(evt.target.value): + self.invite_by_jid(evt.target.value) + evt.target.value = "" + + def on_contact_focus(self, evt): + search_dialog = document['invitation_contact_search'] + search_dialog.classList.add('open') + self._active_new_item = None + evt.target.dispatchEvent(window.Event.new('input')) + + def on_contact_blur(self, evt): + search_dialog = document['invitation_contact_search'] + search_dialog.classList.remove('open') + for elt in self._filtered_new_items.values(): + elt.remove() + self._filtered_new_items.clear() + + + def on_contact_input(self, evt): + text = evt.target.value.strip().lower() + search_dialog = document['invitation_contact_search'] + content_elt = search_dialog.select_one(".search_dialog__content") + for (entity_jid, identity) in cache.identities.items(): + if not cache.match_identity(entity_jid, text, identity): + # if the entity was present in last pass, we remove it + try: + filtered_item = self._filtered_new_items.pop(entity_jid) + except KeyError: + pass + else: + filtered_item.remove() + continue + if entity_jid not in self._filtered_new_items: + # we only create a new element if the item was not already there + new_item_elt = self.new_item_tpl.get_elt({ + "entity_jid": entity_jid, + "identities": cache.identities, + }) + content_elt <= new_item_elt + self._filtered_new_items[entity_jid] = new_item_elt + for elt in new_item_elt.select('.click_to_ok'): + # we use mousedown instead of click because otherwise it would be + # ignored due to "blur" event manager (see + # https://stackoverflow.com/a/9335401) + elt.bind( + "mousedown", + lambda evt, entity_jid=entity_jid: self.on_contact_invite( + evt, entity_jid), + ) + + if ((self._active_new_item is not None + and not self._active_new_item.parentElement)): + # active item has been filtered out + self._active_new_item = None + + def _on_email_invitation_success(self, invitee_jid, email, name): + self.set_affiliation(invitee_jid, "member") + dialog.notification.show( + f"{name} has been invited, he/she has received an email with a link", + level="success", + ) + + def invitation_simple_create_cb(self, invitation_data, email, name): + invitee_jid = invitation_data['jid'] + self._invite_jid( + invitee_jid, + callback=lambda: self._on_email_invitation_success(invitee_jid, email, name), + errback=lambda e: dialog.notification.show( + f"invitation failed for {email}: {e}", + "error" + ) + ) + + # we update identities to have the name instead of the invitation jid in + # affiliations + cache.identities[invitee_jid] = {'nicknames': [name]} + cache.update() + + def invite_by_email(self, email, name): + guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}' + bridge.invitation_simple_create( + email, + name, + guest_url_tpl, + '', + callback=lambda data: self.invitation_simple_create_cb(data, email, name), + errback=lambda e: window.alert(f"can't send email invitation: {e}") + ) + + def on_invite_email_submit(self, evt, invite_email_elt): + evt.stopPropagation() + evt.preventDefault() + form = document['email_invitation_form'] + try: + reportValidity = form.reportValidity + except AttributeError: + print("reportValidity is not supported by this browser!") + else: + if not reportValidity(): + return + email = form.select_one('input[name="email"]').value + name = form.select_one('input[name="name"]').value + self.invite_by_email(email, name) + invite_email_elt.remove() + self.open() + + def on_invite_email_close(self, evt, invite_email_elt): + evt.stopPropagation() + evt.preventDefault() + invite_email_elt.remove() + self.open() + + def on_invite_email_click(self, evt): + evt.stopPropagation() + evt.preventDefault() + invite_email_elt = self.invite_by_email_tpl.get_elt() + document.body <= invite_email_elt + document['email_invitation_submit'].bind( + 'click', lambda evt: self.on_invite_email_submit(evt, invite_email_elt) + ) + for close_elt in invite_email_elt.select('.click_to_close'): + close_elt.bind( + "click", lambda evt: self.on_invite_email_close(evt, invite_email_elt)) + self.close() + + ## affiliations + + def _add_affiliation_bindings(self, entity_jid, affiliation_elt): + for elt in affiliation_elt.select(".click_to_delete"): + elt.bind( + "click", + lambda evt, entity_jid=entity_jid, affiliation_elt=affiliation_elt: + self.on_affiliation_remove(entity_jid, affiliation_elt) + ) + for elt in affiliation_elt.select(".click_to_set_publisher"): + try: + name = cache.identities[entity_jid]["nicknames"][0] + except (KeyError, IndexError): + name = entity_jid + elt.bind( + "click", + lambda evt, entity_jid=entity_jid, name=name, + affiliation_elt=affiliation_elt: + self.on_affiliation_set( + entity_jid, name, affiliation_elt, "publisher" + ), + ) + for elt in affiliation_elt.select(".click_to_set_member"): + try: + name = cache.identities[entity_jid]["nicknames"][0] + except (KeyError, IndexError): + name = entity_jid + elt.bind( + "click", + lambda evt, entity_jid=entity_jid, name=name, + affiliation_elt=affiliation_elt: + self.on_affiliation_set( + entity_jid, name, affiliation_elt, "member" + ), + ) + + def set_affiliation(self, entity_jid, affiliation): + if affiliation not in ('owner', 'member', 'publisher'): + raise NotImplementedError( + f'{affiliation} affiliation can not be set with this method for the ' + 'moment') + if entity_jid not in self.affiliations: + self.affiliations[entity_jid] = affiliation + affiliation_elt = self.affiliation_tpl.get_elt({ + "entity_jid": entity_jid, + "affiliation": affiliation, + "identities": cache.identities, + }) + document['affiliations'] <= affiliation_elt + self._add_affiliation_bindings(entity_jid, affiliation_elt) + + def _on_affiliation_remove_success(self, affiliation_elt, entity_jid): + affiliation_elt.remove() + del self.affiliations[entity_jid] + + def on_affiliation_remove(self, entity_jid, affiliation_elt): + if self.invitation_type == 'photos': + path = self.invitation_data["path"] + service = self.invitation_data["service"] + bridge.fis_affiliations_set( + service, + "", + path, + {entity_jid: "none"}, + callback=lambda: self._on_affiliation_remove_success( + affiliation_elt, entity_jid), + errback=lambda e: dialog.notification.show( + f"can't remove affiliation: {e}", "error") + ) + elif self.invitation_type == 'pubsub': + service = self.invitation_data["service"] + node = self.invitation_data["node"] + bridge.ps_node_affiliations_set( + service, + node, + {entity_jid: "none"}, + callback=lambda: self._on_affiliation_remove_success( + affiliation_elt, entity_jid), + errback=lambda e: dialog.notification.show( + f"can't remove affiliation: {e}", "error") + ) + else: + dialog.notification.show( + f"error: unknown invitation type: {self.invitation_type}", + "error" + ) + + def _on_affiliation_set_success(self, entity_jid, name, affiliation_elt, affiliation): + dialog.notification.show(f"permission updated for {name}") + self.affiliations[entity_jid] = affiliation + new_affiliation_elt = self.affiliation_tpl.get_elt({ + "entity_jid": entity_jid, + "affiliation": affiliation, + "identities": cache.identities, + }) + affiliation_elt.replaceWith(new_affiliation_elt) + self._add_affiliation_bindings(entity_jid, new_affiliation_elt) + + def _on_affiliation_set_ok(self, entity_jid, name, affiliation_elt, affiliation): + if self.invitation_type == 'pubsub': + service = self.invitation_data["service"] + node = self.invitation_data["node"] + bridge.ps_node_affiliations_set( + service, + node, + {entity_jid: affiliation}, + callback=lambda: self._on_affiliation_set_success( + entity_jid, name, affiliation_elt, affiliation + ), + errback=lambda e: dialog.notification.show( + f"can't set affiliation: {e}", "error") + ) + else: + dialog.notification.show( + f"error: unknown invitation type: {self.invitation_type}", + "error" + ) + + def _on_affiliation_set_cancel(self, evt, notif_elt): + notif_elt.remove() + self.open() + + def on_affiliation_set(self, entity_jid, name, affiliation_elt, affiliation): + if affiliation == "publisher": + message = f"Give autorisation to publish to {name}?" + elif affiliation == "member": + message = f"Remove autorisation to publish from {name}?" + else: + dialog.notification.show(f"unmanaged affiliation: {affiliation}", "error") + return + dialog.Confirm(message).show( + ok_cb=lambda evt, notif_elt: + self._on_affiliation_set_ok( + entity_jid, name, affiliation_elt, affiliation + ), + cancel_cb=self._on_affiliation_set_cancel + ) + self.close()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/loading.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,7 @@ +"""manage common dialogs""" + +from browser import document + +def remove_loading_screen(): + print("removing loading screen") + document['loading_screen'].remove()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/slideshow.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,262 @@ +from browser import document, window, html, timer, DOMNode +from js_modules.swiper import Swiper +from template import Template + + +class SlideShow: + + def __init__(self): + self.swiper = None + slideshow_tpl = Template('photo/slideshow.html') + self.slideshow_elt = slideshow_tpl.get_elt() + self.comments_count_elt = self.slideshow_elt.select_one('.comments__count') + self.wrapper = self.slideshow_elt.select_one(".swiper-wrapper") + self.hidden_elts = {} + self.control_hidden = False + self.click_timer = None + self._class_to_remove = set() + self._exit_callback = None + + @property + def current_slide(self): + if self.swiper is None: + return None + try: + return DOMNode(self.swiper.slides[self.swiper.realIndex]) + # getting missing item in JS arrays returns KeyError + except KeyError: + return None + + @property + def current_item(self): + """item attached to the current slide, if any""" + current = self.current_slide + if current is None: + return None + try: + return current._item + except AttributeError: + return None + + @property + def current_options(self): + """options attached to the current slide, if any""" + current = self.current_slide + if current is None: + return None + try: + return current._options + except AttributeError: + return None + + @property + def index(self): + if self.swiper is None: + return None + return self.swiper.realIndex + + @index.setter + def index(self, idx): + if self.swiper is not None: + self.swiper.slideTo(idx, 0) + + def attach(self): + # we hide other elts to avoid scrolling issues + for elt in document.body.children: + try: + self.hidden_elts[elt] = elt.style.display + except AttributeError: + pass + # FIXME: this is a workaround needed because Brython's children method + # is returning all nodes, + # cf. https://github.com/brython-dev/brython/issues/1657 + # to be removed when Brython is fixed. + else: + elt.style.display = "none" + document.body <= self.slideshow_elt + self.swiper = Swiper.new( + ".swiper-container", + { + # default 0 value results in lot of accidental swipes, notably when media + # player is used + "threshold": 10, + "pagination": { + "el": ".swiper-pagination", + }, + "navigation": { + "nextEl": ".swiper-button-next", + "prevEl": ".swiper-button-prev", + }, + "scrollbar": { + "el": ".swiper-scrollbar", + }, + "grabCursor": True, + "keyboard": { + "enabled": True, + "onlyInViewport": False, + }, + "mousewheel": True, + "zoom": { + "maxRatio": 15, + "toggle": False, + }, + } + ) + window.addEventListener("keydown", self.on_key_down, True) + self.slideshow_elt.select_one(".click_to_close").bind("click", self.on_close) + self.slideshow_elt.select_one(".click_to_comment").bind("click", self.on_comment) + + # we don't use swiper.on for "click" and "dblclick" (or "doubleTap" in swiper + # terms) because it breaks event propagation management, which cause trouble with + # media player + self.slideshow_elt.bind("click", self.on_click) + self.slideshow_elt.bind("dblclick", self.on_dblclick) + self.swiper.on("slideChange", self.on_slide_change) + self.on_slide_change(self.swiper) + self.fullscreen(True) + + def add_slide(self, elt, item_data=None, options=None): + slide_elt = html.DIV(Class="swiper-slide") + zoom_cont_elt = html.DIV(Class="swiper-zoom-container") + slide_elt <= zoom_cont_elt + zoom_cont_elt <= elt + slide_elt._item = item_data + if options is not None: + slide_elt._options = options + self.swiper.appendSlide([slide_elt]) + self.swiper.update() + + def quit(self): + # we unhide + for elt, display in self.hidden_elts.items(): + elt.style.display = display + self.hidden_elts.clear() + self.slideshow_elt.remove() + self.slideshow_elt = None + self.swiper.destroy(True, True) + self.swiper = None + + def fullscreen(self, active=None): + """Activate/desactivate fullscreen + + @param acvite: can be: + - True to activate + - False to desactivate + - Auto to switch fullscreen mode + """ + try: + fullscreen_elt = document.fullscreenElement + request_fullscreen = self.slideshow_elt.requestFullscreen + except AttributeError: + print("fullscreen is not available on this browser") + else: + if active is None: + active = fullscreen_elt == None + if active: + request_fullscreen() + else: + try: + document.exitFullscreen() + except AttributeError: + print("exitFullscreen not available on this browser") + + def on_key_down(self, evt): + if evt.key == 'Escape': + self.quit() + else: + return + evt.preventDefault() + + def on_slide_change(self, swiper): + if self._exit_callback is not None: + self._exit_callback() + self._exit_callback = None + item = self.current_item + if item is not None: + comments_count = item.get('comments_count') + self.comments_count_elt.text = comments_count or '' + + for cls in self._class_to_remove: + self.slideshow_elt.classList.remove(cls) + + self._class_to_remove.clear() + + options = self.current_options + if options is not None: + for flag in options.get('flags', []): + cls = f"flag_{flag.lower()}" + self.slideshow_elt.classList.add(cls) + self._class_to_remove.add(cls) + self._exit_callback = options.get("exit_callback", None) + + def toggle_hide_controls(self, evt): + self.click_timer = None + # we don't want to hide controls when a control is clicked + # so we check all ancestors if we are not in a control + current = evt.target + while current and current != self.slideshow_elt: + print(f"current: {current}") + if 'slideshow_control' in current.classList: + return + current = current.parent + for elt in self.slideshow_elt.select('.slideshow_control'): + elt.style.display = '' if self.control_hidden else 'none' + self.control_hidden = not self.control_hidden + + def on_click(self, evt): + evt.stopPropagation() + evt.preventDefault() + # we use a timer so double tap can cancel the click + # this avoid double tap side effect + if self.click_timer is None: + self.click_timer = timer.set_timeout( + lambda: self.toggle_hide_controls(evt), 300) + + def on_dblclick(self, evt): + evt.stopPropagation() + evt.preventDefault() + if self.click_timer is not None: + timer.clear_timeout(self.click_timer) + self.click_timer = None + if self.swiper.zoom.scale != 1: + self.swiper.zoom.toggle() + else: + # "in" is reserved in Python, so we call it using dict syntax + self.swiper.zoom["in"]() + + def on_close(self, evt): + evt.stopPropagation() + evt.preventDefault() + self.quit() + + def on_comment_close(self, evt): + evt.stopPropagation() + side_panel = self.comments_panel_elt.select_one('.comments_side_panel') + side_panel.classList.remove('open') + side_panel.bind("transitionend", lambda evt: self.comments_panel_elt.remove()) + + def on_comments_panel_click(self, evt): + # we stop stop propagation to avoid the closing of the panel + evt.stopPropagation() + + def on_comment(self, evt): + item = self.current_item + if item is None: + return + comments_panel_tpl = Template('blog/comments_panel.html') + try: + comments = item['comments']['items'] + except KeyError: + comments = [] + self.comments_panel_elt = comments_panel_tpl.get_elt({ + "comments": comments, + "comments_service": item['comments_service'], + "comments_node": item['comments_node'], + + }) + self.slideshow_elt <= self.comments_panel_elt + side_panel = self.comments_panel_elt.select_one('.comments_side_panel') + timer.set_timeout(lambda: side_panel.classList.add("open"), 0) + for close_elt in self.comments_panel_elt.select('.click_to_close'): + close_elt.bind("click", self.on_comment_close) + side_panel.bind("click", self.on_comments_panel_click)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/template.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,235 @@ +"""Integrate templating system using nunjucks""" + +from js_modules.nunjucks import nunjucks +from browser import window, document +import javascript + + +safe = nunjucks.runtime.SafeString.new +env = nunjucks.configure( + window.templates_root_url, + { + 'autoescape': True, + 'trimBlocks': True, + 'lstripBlocks': True, + 'web': {'use_cache': True}, + }) + +nunjucks.installJinjaCompat() +env.addGlobal("profile", window.profile) +env.addGlobal("csrf_token", window.csrf_token) +# FIXME: integrate gettext or equivalent here +env.addGlobal("_", lambda txt: txt) + + +class Indexer: + """Index global to a page""" + + def __init__(self): + self._indexes = {} + + def next(self, value): + if value not in self._indexes: + self._indexes[value] = 0 + return 0 + self._indexes[value] += 1 + return self._indexes[value] + + def current(self, value): + return self._indexes.get(value) + + +gidx = Indexer() +# suffix use to avoid collision with IDs generated in static page +SCRIPT_SUFF = "__script__" + +def escape_html(txt): + return ( + txt + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + ) + + +def get_args(n_args, *sig_args, **sig_kwargs): + """Retrieve function args when they are transmitted using nunjucks convention + + cf. https://mozilla.github.io/nunjucks/templating.html#keyword-arguments + @param n_args: argument from nunjucks call + @param sig_args: expected positional arguments + @param sig_kwargs: expected keyword arguments + @return: all expected arguments, with default value if not specified in nunjucks + """ + # nunjucks set kwargs in last argument + given_args = list(n_args) + try: + given_kwargs = given_args.pop().to_dict() + except (AttributeError, IndexError): + # we don't have a dict as last argument + # that happens when there is no keyword argument + given_args = list(n_args) + given_kwargs = {} + ret = given_args[:len(sig_args)] + # we check if we have remaining positional arguments + # in which case they may be specified in keyword arguments + for name in sig_args[len(given_args):]: + try: + value = given_kwargs.pop(name) + except KeyError: + raise ValueError(f"missing positional arguments {name!r}") + ret.append(value) + + extra_pos_args = given_args[len(sig_args):] + # and now the keyword arguments + for name, default in sig_kwargs.items(): + if extra_pos_args: + # kw args has been specified with a positional argument + ret.append(extra_pos_args.pop(0)) + continue + value = given_kwargs.get(name, default) + ret.append(value) + + return ret + + +def _next_gidx(value): + """Use next current global index as suffix""" + next_ = gidx.next(value) + return f"{value}{SCRIPT_SUFF}" if next_ == 0 else f"{value}_{SCRIPT_SUFF}{next_}" + +env.addFilter("next_gidx", _next_gidx) + + +def _cur_gidx(value): + """Use current current global index as suffix""" + current = gidx.current(value) + return f"{value}{SCRIPT_SUFF}" if not current else f"{value}_{SCRIPT_SUFF}{current}" + +env.addFilter("cur_gidx", _cur_gidx) + + +def _xmlattr(d, autospace=True): + if not d: + return + d = d.to_dict() + ret = [''] if autospace else [] + for key, value in d.items(): + if value is not None: + ret.append(f'{escape_html(key)}="{escape_html(str(value))}"') + + return safe(' '.join(ret)) + +env.addFilter("xmlattr", _xmlattr) + + +def _tojson(value): + return safe(escape_html(window.JSON.stringify(value))) + +env.addFilter("tojson", _tojson) + + +def _icon_use(name, cls=""): + kwargs = cls.to_dict() + cls = kwargs.get('cls') + return safe( + '<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" ' + 'viewBox="0 0 100 100">\n' + ' <use href="#{name}"/>' + '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "") + ) + +env.addGlobal("icon", _icon_use) + + +def _date_fmt( + timestamp, *args +): + """Date formatting + + cf. libervia.backend.tools.common.date_utils for arguments details + """ + fmt, date_only, auto_limit, auto_old_fmt, auto_new_fmt = get_args( + args, fmt="short", date_only=False, auto_limit=7, auto_old_fmt="short", + auto_new_fmt="relative", + ) + from js_modules.moment import moment + date = moment.unix(timestamp) + + if fmt == "auto_day": + fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm" + if fmt == "auto": + limit = moment().startOf('day').subtract(auto_limit, 'days') + m_fmt = auto_old_fmt if date < limit else auto_new_fmt + + if fmt == "short": + m_fmt = "DD/MM/YY" if date_only else "DD/MM/YY HH:mm" + elif fmt == "medium": + m_fmt = "ll" if date_only else "lll" + elif fmt == "long": + m_fmt = "LL" if date_only else "LLL" + elif fmt == "full": + m_fmt = "dddd, LL" if date_only else "LLLL" + elif fmt == "relative": + return date.fromNow() + elif fmt == "iso": + if date_only: + m_fmt == "YYYY-MM-DD" + else: + return date.toISOString() + else: + raise NotImplementedError("free format is not implemented yet") + + return date.format(m_fmt) + +env.addFilter("date_fmt", _date_fmt) + + +class I18nExtension: + """Extension to handle the {% trans %}{% endtrans %} statement""" + # FIXME: for now there is no translation, this extension only returns the string + # unmodified + tags = ['trans'] + + def parse(self, parser, nodes, lexer): + tok = parser.nextToken() + args = parser.parseSignature(None, True) + parser.advanceAfterBlockEnd(tok.value) + body = parser.parseUntilBlocks('endtrans') + parser.advanceAfterBlockEnd() + return nodes.CallExtension.new(self._js_ext, 'run', args, [body]) + + def run(self, context, *args): + body = args[-1] + return body() + + @classmethod + def install(cls, env): + ext = cls() + ext_dict = { + "tags": ext.tags, + "parse": ext.parse, + "run": ext.run + } + ext._js_ext = javascript.pyobj2jsobj(ext_dict) + env.addExtension(cls.__name__, ext._js_ext) + +I18nExtension.install(env) + + +class Template: + + def __init__(self, tpl_name): + self._tpl = env.getTemplate(tpl_name, True) + + def render(self, context): + return self._tpl.render(context) + + def get_elt(self, context=None): + if context is None: + context = {} + raw_html = self.render(context) + template_elt = document.createElement('template') + template_elt.innerHTML = raw_html + return template_elt.content.firstElementChild
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/_browser/tmp_aio.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,26 @@ +from browser import window + +"""Q&D module to do ajax requests with data types currently unsupported by Brython""" +# FIXME: remove this module when official aio module allows to work with blobs + +window.eval(""" +var _tmp_ajax = function(method, url, format, data){ + return new Promise(function(resolve, reject){ + var xhr = new XMLHttpRequest() + xhr.open(method, url, true) + xhr.responseType = format + xhr.onreadystatechange = function(){ + if(this.readyState == 4){ + resolve(this) + } + } + if(data){ + xhr.send(data) + }else{ + xhr.send() + } + }) +} +""") + +ajax = window._tmp_ajax
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/app/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + + +name = "app" +template = "app/app.html"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/edit/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,6 @@ +import editor + + +editor.set_form_autosave("blog_post_edit") +editor.BlogEditor() +editor.TagsEditor("input[name='tags']")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/edit/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format + +log = getLogger(__name__) + +name = "blog_edit" +access = C.PAGES_ACCESS_PROFILE +template = "blog/publish.html" + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if profile is None: + self.page_error(request, C.HTTP_FORBIDDEN) + request_data = self.get_r_data(request) + title, tags, body = self.get_posted_data(request, ('title', 'tags', 'body')) + mb_data = {"content_rich": body, "allow_comments": True} + title = title.strip() + if title: + mb_data["title_rich"] = title + tags = [t.strip() for t in tags.split(',') if t.strip()] + if tags: + mb_data["tags"] = tags + + await self.host.bridge_call( + 'mb_send', + "", + "", + data_format.serialise(mb_data), + profile + ) + + request_data["post_redirect_page"] = self.get_page_by_name("blog")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +from libervia.backend.core.i18n import _ +from libervia.web.server.constants import Const as C +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from libervia.web.server import session_iface +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "blog" +access = C.PAGES_ACCESS_PUBLIC +template = "blog/discover.html" + + +async def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + if profile is not None: + __, entities_own, entities_roster = await self.host.bridge_call( + "disco_find_by_features", + [], + [("pubsub", "pep")], + True, + False, + True, + True, + True, + profile, + ) + entities = template_data["disco_entities"] = ( + list(entities_own.keys()) + list(entities_roster.keys()) + ) + entities_url = template_data["entities_url"] = {} + identities = self.host.get_session_data( + request, session_iface.IWebSession + ).identities + d_list = {} + for entity_jid_s in entities: + entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url( + entity_jid_s + ) + if entity_jid_s not in identities: + d_list[entity_jid_s] = self.host.bridge_call( + "identity_get", + entity_jid_s, + [], + True, + profile) + identities_data = await defer.DeferredList(d_list.values()) + entities_idx = list(d_list.keys()) + for idx, (success, identity_raw) in enumerate(identities_data): + entity_jid_s = entities_idx[idx] + if not success: + log.warning(_("Can't retrieve identity of {entity}") + .format(entity=entity_jid_s)) + else: + identities[entity_jid_s] = data_format.deserialise(identity_raw) + + template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit") + + +def on_data_post(self, request): + jid_str = self.get_posted_data(request, "jid") + try: + jid_ = jid.JID(jid_str) + except RuntimeError: + self.page_error(request, C.HTTP_BAD_REQUEST) + url = self.get_page_by_name("blog_view").get_url(jid_.full()) + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/view/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +import alt_media_player + + +alt_media_player.install_if_needed()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/view/atom.xml/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from libervia.backend.tools.common import uri +import time + +name = "blog_feed_atom" +access = C.PAGES_ACCESS_PUBLIC +template = "blog/atom.xml" + + +async def prepare_render(self, request): + request.setHeader("Content-Type", "application/atom+xml; charset=utf-8") + data = self.get_r_data(request) + service, node = data["service"], data.get("node") + self.check_cache( + request, C.CACHE_PUBSUB, service=service, node=node, short="microblog" + ) + data["show_comments"] = False + template_data = request.template_data + blog_page = self.get_page_by_name("blog_view") + await blog_page.prepare_render(self, request) + items = data["blog_items"]['items'] + + template_data["request_uri"] = self.host.get_ext_base_url( + request, request.path.decode("utf-8") + ) + template_data["xmpp_uri"] = uri.build_xmpp_uri( + "pubsub", subtype="microblog", path=service.full(), node=node + ) + blog_view = self.get_page_by_name("blog_view") + template_data["http_uri"] = self.host.get_ext_base_url( + request, blog_view.get_url(service.full(), node) + ) + if items: + template_data["updated"] = items[0]['updated'] + else: + template_data["updated"] = time.time()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/blog/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 + +import html +from typing import Any, Dict, Optional + +from libervia.backend.core.i18n import D_, _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import uri +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import regex +from libervia.backend.tools.common.template import safe +from twisted.web import server +from twisted.words.protocols.jabber import jid + +from libervia.web.server import utils +from libervia.web.server.constants import Const as C +from libervia.web.server.utils import SubPage + +log = getLogger(__name__) + +"""generic blog (with service/node provided)""" +name = 'blog_view' +template = "blog/articles.html" +uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'} + +URL_LIMIT_MARK = 90 # if canonical URL is longer than that, text will not be appended + + +def microblog_uri(self, uri_data): + args = [uri_data['path'], uri_data['node']] + if 'item' in uri_data: + args.extend(['id', uri_data['item']]) + return self.get_url(*args) + +def parse_url(self, request): + """URL is /[service]/[node]/[filter_keyword]/[item]|[other] + + if [node] is '@', default namespace is used + if a value is unset, default one will be used + keyword can be one of: + id: next value is a item id + tag: next value is a blog tag + """ + data = self.get_r_data(request) + + try: + service = self.next_path(request) + except IndexError: + data['service'] = '' + else: + try: + data["service"] = jid.JID(service) + except Exception: + log.warning(_("bad service entered: {}").format(service)) + self.page_error(request, C.HTTP_BAD_REQUEST) + + try: + node = self.next_path(request) + except IndexError: + node = '@' + data['node'] = '' if node == '@' else node + + try: + filter_kw = data['filter_keyword'] = self.next_path(request) + except IndexError: + filter_kw = '@' + else: + if filter_kw == '@': + # No filter, this is used when a subpage is needed, notably Atom feed + pass + elif filter_kw == 'id': + try: + data['item'] = self.next_path(request) + except IndexError: + self.page_error(request, C.HTTP_BAD_REQUEST) + # we get one more argument in case text has been added to have a nice URL + try: + self.next_path(request) + except IndexError: + pass + elif filter_kw == 'tag': + try: + data['tag'] = self.next_path(request) + except IndexError: + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + # invalid filter keyword + log.warning(_("invalid filter keyword: {filter_kw}").format( + filter_kw=filter_kw)) + self.page_error(request, C.HTTP_BAD_REQUEST) + + # if URL is parsed here, we'll have atom.xml available and we need to + # add the link to the page + atom_url = self.get_url_by_path( + SubPage('blog_view'), + service, + node, + filter_kw, + SubPage('blog_feed_atom'), + ) + request.template_data['atom_url'] = atom_url + request.template_data.setdefault('links', []).append({ + "href": atom_url, + "type": "application/atom+xml", + "rel": "alternate", + "title": "{service}'s blog".format(service=service)}) + + +def add_breadcrumb(self, request, breadcrumbs): + data = self.get_r_data(request) + breadcrumbs.append({ + "label": D_("Feed"), + "url": self.get_url(data["service"].full(), data.get("node", "@")) + }) + if "item" in data: + breadcrumbs.append({ + "label": D_("Post"), + }) + + +async def append_comments( + self, + request: server.Request, + blog_items: dict, + profile: str, + _seen: Optional[set] = None +) -> None: + """Recursively download and append comments of items + + @param blog_items: items data + @param profile: Libervia profile + @param _seen: used to avoid infinite recursion. For internal use only + """ + if _seen is None: + _seen = set() + await self.fill_missing_identities( + request, [i['author_jid'] for i in blog_items['items']]) + extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION} + if not self.use_cache(request): + extra[C.KEY_USE_CACHE] = False + for blog_item in blog_items['items']: + for comment_data in blog_item['comments']: + service = comment_data['service'] + node = comment_data['node'] + service_node = (service, node) + if service_node in _seen: + log.warning( + f"Items from {node!r} at {service} have already been retrieved, " + "there is a recursion at this service" + ) + comment_data["items"] = [] + continue + else: + _seen.add(service_node) + try: + comments_data = await self.host.bridge_call('mb_get', + service, + node, + C.NO_LIMIT, + [], + data_format.serialise( + extra + ), + profile) + except Exception as e: + log.warning( + _("Can't get comments at {node} (service: {service}): {msg}").format( + service=service, + node=node, + msg=e)) + comment_data['items'] = [] + continue + + comments = data_format.deserialise(comments_data) + if comments is None: + log.error(f"Comments should not be None: {comment_data}") + comment_data["items"] = [] + continue + comment_data['items'] = comments['items'] + await append_comments(self, request, comments, profile, _seen=_seen) + +async def get_blog_items( + self, + request: server.Request, + service: jid.JID, + node: str, + item_id, + extra: Dict[str, Any], + profile: str +) -> dict: + try: + if item_id: + items_id = [item_id] + else: + items_id = [] + if not self.use_cache(request): + extra[C.KEY_USE_CACHE] = False + blog_data = await self.host.bridge_call('mb_get', + service.userhost(), + node, + C.NO_LIMIT, + items_id, + data_format.serialise(extra), + profile) + except Exception as e: + # FIXME: need a better way to test errors in bridge errback + if "forbidden" in str(e): + self.page_error(request, 401) + else: + log.warning(_("can't retrieve blog for [{service}]: {msg}".format( + service = service.userhost(), msg=e))) + blog_data = {"items": []} + else: + blog_data = data_format.deserialise(blog_data) + + return blog_data + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + page_max = data.get("page_max", 10) + # if the comments are not explicitly hidden, we show them + service, node, item_id, show_comments = ( + data.get('service', ''), + data.get('node', ''), + data.get('item'), + data.get('show_comments', True) + ) + profile = self.get_profile(request) + if profile is None: + profile = C.SERVICE_PROFILE + profile_connected = False + else: + profile_connected = True + + ## pagination/filtering parameters + if item_id: + extra = {} + else: + extra = self.get_pubsub_extra(request, page_max=page_max) + tag = data.get('tag') + if tag: + extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag + self.handle_search(request, extra) + + ## main data ## + # we get data from backend/XMPP here + blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile) + + ## navigation ## + # no let's fill service, node and pagination URLs + if 'service' not in template_data: + template_data['service'] = service + if 'node' not in template_data: + template_data['node'] = node + target_profile = template_data.get('target_profile') + + if blog_items: + if item_id: + template_data["previous_page_url"] = self.get_url( + service.full(), + node, + before=item_id, + page_max=1 + ) + template_data["next_page_url"] = self.get_url( + service.full(), + node, + after=item_id, + page_max=1 + ) + blog_items["rsm"] = { + "last": item_id, + "first": item_id, + } + blog_items["complete"] = False + else: + self.set_pagination(request, blog_items) + else: + if item_id: + # if item id has been specified in URL and it's not found, + # we must return an error + self.page_error(request, C.HTTP_NOT_FOUND) + + ## identities ## + # identities are used to show nice nickname or avatars + await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']]) + + ## Comments ## + # if comments are requested, we need to take them + if show_comments: + await append_comments(self, request, blog_items, profile) + + ## URLs ## + # We will fill items_http_uri and tags_http_uri in template_data with suitable urls + # if we know the profile, we use it instead of service + blog (nicer url) + if target_profile is None: + blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id') + blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag') + else: + blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])]) + blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])]) + # we also set the background image if specified by user + bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile']) + if bg_img: + template_data['dynamic_style'] = safe(""" + :root { + --bg-img: url("%s"); + } + """ % html.escape(bg_img, True)) + + template_data['blog_items'] = data['blog_items'] = blog_items + if request.args.get(b'reverse') == ['1']: + template_data['blog_items'].items.reverse() + template_data['items_http_uri'] = items_http_uri = {} + template_data['tags_http_uri'] = tags_http_uri = {} + + + for item in blog_items['items']: + blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])]) + if len(blog_canonical_url) > URL_LIMIT_MARK: + blog_url = blog_canonical_url + elif '-' not in item['id']: + # we add text from title or body at the end of URL + # to make it more human readable + # we do it only if there is no "-", as a "-" probably means that + # item's id is already user friendly. + # TODO: to be removed, this is only kept for a transition period until + # user friendly item IDs are more common. + text = regex.url_friendly_text(item.get('title', item['content'])) + if text: + blog_url = blog_canonical_url + '/' + text + else: + blog_url = blog_canonical_url + else: + blog_url = blog_canonical_url + + items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url) + for tag in item['tags']: + if tag not in tags_http_uri: + tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)]) + tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url) + + # if True, page should display a comment box + template_data['allow_commenting'] = data.get('allow_commenting', profile_connected) + + # last but not least, we add a xmpp: link to the node + uri_args = {'path': service.full()} + if node: + uri_args['node'] = node + if item_id: + uri_args['item'] = item_id + template_data['xmpp_uri'] = uri.build_xmpp_uri( + 'pubsub', subtype='microblog', **uri_args + ) + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if profile is None: + self.page_error(request, C.HTTP_FORBIDDEN) + type_ = self.get_posted_data(request, 'type') + if type_ == 'comment': + service, node, body = self.get_posted_data(request, ('service', 'node', 'body')) + + if not body: + self.page_error(request, C.HTTP_BAD_REQUEST) + comment_data = {"content_rich": body} + try: + await self.host.bridge_call('mb_send', + service, + node, + data_format.serialise(comment_data), + profile) + except Exception as e: + if "forbidden" in str(e): + self.page_error(request, 401) + else: + raise e + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/calendar/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,25 @@ +from browser import document, window +from browser.timer import set_interval + +calendar_start = window.calendar_start + + +def update_current_time_line(): + now = window.Date.new() + + # Calculate the position of the current-time-line + now_ts = now.getTime() / 1000 + minutes_passed = (now_ts - calendar_start) / 60 + + new_top = minutes_passed + 15 + + # Update the current-time-line position and make it visible + current_time_line = document["current-time-line"] + current_time_line.style.top = f"{new_top}px" + current_time_line.hidden = False + +# Initial update +update_current_time_line() + +# Update the current-time-line every minute +set_interval(update_current_time_line, 60 * 1000)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/calendar/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format +from twisted.internet import defer +import datetime +import time +from dateutil import tz + +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + + +name = "calendar" +access = C.PAGES_ACCESS_PROFILE +template = "calendar/daily.html" + + +async def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + # template_data["url_event_new"] = self.get_sub_page_url(request, "event_new") + if profile is not None: + try: + events = data_format.deserialise( + await self.host.bridge_call("events_get", "", "", [], "", profile), + type_check=list + ) + except Exception as e: + log.warning(_("Can't get events list for {profile}: {reason}").format( + profile=profile, reason=e)) + else: + template_data["events"] = events + + tz_name = template_data["tz_name"] = time.tzname[0] + local_tz = tz.tzlocal() + today_local = datetime.datetime.now(local_tz).date() + calendar_start = template_data["calendar_start"] = datetime.datetime.combine( + today_local, datetime.time.min, tzinfo=local_tz + ).timestamp() + calendar_end = template_data["calendar_end"] = datetime.datetime.combine( + today_local, datetime.time.max, tzinfo=local_tz + ).timestamp() + self.expose_to_scripts( + request, + calendar_start=calendar_start, + calendar_end=calendar_end, + tz_name=tz_name, + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/calls/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,513 @@ +import json +import re + +from bridge import AsyncBridge as Bridge +from browser import aio, console as log, document, timer, window +import errors +import loading + +log.warning = log.warn +profile = window.profile or "" +bridge = Bridge() +GATHER_TIMEOUT = 10000 + + +class WebRTCCall: + + def __init__(self): + self.reset_instance() + + def reset_instance(self): + """Inits or resets the instance variables to their default state.""" + self._peer_connection = None + self._media_types = None + self.sid = None + self.local_candidates = None + self.remote_stream = None + self.candidates_buffer = { + "audio": {"candidates": []}, + "video": {"candidates": []}, + } + self.media_candidates = {} + self.candidates_gathered = aio.Future() + + @property + def media_types(self): + if self._media_types is None: + raise Exception("self._media_types should not be None!") + return self._media_types + + def get_sdp_mline_index(self, media_type): + """Gets the sdpMLineIndex for a given media type. + + @param media_type: The type of the media. + """ + for index, m_type in self.media_types.items(): + if m_type == media_type: + return index + raise ValueError(f"Media type '{media_type}' not found") + + def extract_pwd_ufrag(self, sdp): + """Retrieves ICE password and user fragment for SDP offer. + + @param sdp: The Session Description Protocol offer string. + """ + ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) + pwd_line = re.search(r"ice-pwd:(\S+)", sdp) + + if ufrag_line and pwd_line: + return ufrag_line.group(1), pwd_line.group(1) + else: + log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") + raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") + + def extract_fingerprint_data(self, sdp): + """Retrieves fingerprint data from an SDP offer. + + @param sdp: The Session Description Protocol offer string. + @return: A dictionary containing the fingerprint data. + """ + fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp) + if fingerprint_line: + algorithm, fingerprint = fingerprint_line.groups() + fingerprint_data = { + "hash": algorithm, + "fingerprint": fingerprint + } + + setup_line = re.search(r"a=setup:(\S+)", sdp) + if setup_line: + setup = setup_line.group(1) + fingerprint_data["setup"] = setup + + return fingerprint_data + else: + raise ValueError("fingerprint should not be missing") + + def parse_ice_candidate(self, candidate_string): + """Parses the ice candidate string. + + @param candidate_string: The ice candidate string to be parsed. + """ + pattern = re.compile( + r"candidate:(?P<foundation>\S+) (?P<component_id>\d+) (?P<transport>\S+) " + r"(?P<priority>\d+) (?P<address>\S+) (?P<port>\d+) typ " + r"(?P<type>\S+)(?: raddr (?P<rel_addr>\S+) rport " + r"(?P<rel_port>\d+))?(?: generation (?P<generation>\d+))?" + ) + match = pattern.match(candidate_string) + if match: + candidate_dict = match.groupdict() + + # Apply the correct types to the dictionary values + candidate_dict["component_id"] = int(candidate_dict["component_id"]) + candidate_dict["priority"] = int(candidate_dict["priority"]) + candidate_dict["port"] = int(candidate_dict["port"]) + + if candidate_dict["rel_port"]: + candidate_dict["rel_port"] = int(candidate_dict["rel_port"]) + + if candidate_dict["generation"]: + candidate_dict["generation"] = candidate_dict["generation"] + + # Remove None values + return {k: v for k, v in candidate_dict.items() if v is not None} + else: + log.warning(f"can't parse candidate: {candidate_string!r}") + return None + + def build_ice_candidate(self, parsed_candidate): + """Builds ICE candidate + + @param parsed_candidate: Dictionary containing parsed ICE candidate + """ + base_format = ( + "candidate:{foundation} {component_id} {transport} {priority} " + "{address} {port} typ {type}" + ) + + if ((parsed_candidate.get('rel_addr') + and parsed_candidate.get('rel_port'))): + base_format += " raddr {rel_addr} rport {rel_port}" + + if parsed_candidate.get('generation'): + base_format += " generation {generation}" + + return base_format.format(**parsed_candidate) + + def on_ice_candidate(self, event): + """Handles ICE candidate event + + @param event: Event containing the ICE candidate + """ + log.debug(f"on ice candidate {event.candidate=}") + if event.candidate and event.candidate.candidate: + window.last_event = event + parsed_candidate = self.parse_ice_candidate(event.candidate.candidate) + if parsed_candidate is None: + return + try: + media_type = self.media_types[event.candidate.sdpMLineIndex] + except (TypeError, IndexError): + log.error( + f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" + ) + return + self.media_candidates.setdefault(media_type, []).append(parsed_candidate) + log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") + else: + log.debug("All ICE candidates gathered") + + def _set_media_types(self, offer): + """Sets media types from offer SDP + + @param offer: RTC session description containing the offer + """ + sdp_lines = offer.sdp.splitlines() + media_types = {} + mline_index = 0 + + for line in sdp_lines: + if line.startswith("m="): + media_types[mline_index] = line[2:line.find(" ")] + mline_index += 1 + + self._media_types = media_types + + def on_ice_gathering_state_change(self, event): + """Handles ICE gathering state change + + @param event: Event containing the ICE gathering state change + """ + connection = event.target + log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") + if connection.iceGatheringState == "complete": + log.info("ICE candidates gathering done") + self.candidates_gathered.set_result(None) + + async def _create_peer_connection( + self, + ): + """Creates peer connection""" + if self._peer_connection is not None: + raise Exception("create_peer_connection can't be called twice!") + + external_disco = json.loads(await bridge.external_disco_get("")) + ice_servers = [] + + for server in external_disco: + ice_server = {} + if server["type"] == "stun": + ice_server["urls"] = f"stun:{server['host']}:{server['port']}" + elif server["type"] == "turn": + ice_server["urls"] = ( + f"turn:{server['host']}:{server['port']}?transport={server['transport']}" + ) + ice_server["username"] = server["username"] + ice_server["credential"] = server["password"] + ice_servers.append(ice_server) + + rtc_configuration = {"iceServers": ice_servers} + + peer_connection = window.RTCPeerConnection.new(rtc_configuration) + peer_connection.addEventListener("track", self.on_track) + peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed) + peer_connection.addEventListener("icecandidate", self.on_ice_candidate) + peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) + + self._peer_connection = peer_connection + window.pc = self._peer_connection + + async def _get_user_media( + self, + audio: bool = True, + video: bool = True + ): + """Gets user media + + @param audio: True if an audio flux is required + @param video: True if a video flux is required + """ + media_constraints = {'audio': audio, 'video': video} + local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints) + document["local_video"].srcObject = local_stream + + for track in local_stream.getTracks(): + self._peer_connection.addTrack(track) + + async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): + """Get ICE candidates and wait to have them all before returning them + + @param is_initiator: Boolean indicating if the user is the initiator of the connection + @param remote_candidates: Remote ICE candidates, if any + """ + if self._peer_connection is None: + raise Exception("The peer connection must be created before gathering ICE candidates!") + + self.media_candidates.clear() + gather_timeout = timer.set_timeout( + lambda: self.candidates_gathered.set_exception( + errors.TimeoutError("ICE gathering time out") + ), + GATHER_TIMEOUT + ) + + if is_initiator: + offer = await self._peer_connection.createOffer() + self._set_media_types(offer) + await self._peer_connection.setLocalDescription(offer) + else: + answer = await self._peer_connection.createAnswer() + self._set_media_types(answer) + await self._peer_connection.setLocalDescription(answer) + + if not is_initiator: + log.debug(self._peer_connection.localDescription.sdp) + await self.candidates_gathered + log.debug(self._peer_connection.localDescription.sdp) + timer.clear_timeout(gather_timeout) + ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp) + return { + "ufrag": ufrag, + "pwd": pwd, + "candidates": self.media_candidates, + } + + def on_action_new( + self, action_data_s: str, action_id: str, security_limit: int, profile: str + ) -> None: + """Called when a call is received + + @param action_data_s: Action data serialized + @param action_id: Unique identifier for the action + @param security_limit: Security limit for the action + @param profile: Profile associated with the action + """ + action_data = json.loads(action_data_s) + if action_data.get("type") != "call": + return + peer_jid = action_data["from_jid"] + log.info( + f"{peer_jid} wants to start a call ({action_data['sub_type']})" + ) + if self.sid is not None: + log.warning( + f"already in a call ({self.sid}), can't receive a new call from " + f"{peer_jid}" + ) + return + self.sid = action_data["session_id"] + log.debug(f"Call SID: {self.sid}") + + # Answer the call + offer_sdp = action_data["sdp"] + aio.run(self.answer_call(offer_sdp, action_id)) + + def _on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: + """Called when we have received answer SDP from responder + + @param session_id: Session identifier + @param sdp: Session Description Protocol data + @param profile: Profile associated with the action + """ + aio.run(self.on_call_accepted(session_id, sdp, profile)) + + def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None: + """Call has been terminated + + @param session_id: Session identifier + @param data_s: Serialised additional data on why the call has ended + @param profile: Profile associated + """ + if self.sid is None: + log.debug("there are no calls in progress") + return + if session_id != self.sid: + log.debug( + f"ignoring call_ended not linked to our call ({self.sid}): {session_id}" + ) + return + aio.run(self.end_call()) + + async def on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: + """Call has been accepted, connection can be established + + @param session_id: Session identifier + @param sdp: Session Description Protocol data + @param profile: Profile associated + """ + if self.sid != session_id: + log.debug( + f"Call ignored due to different session ID ({self.sid=} {session_id=})" + ) + return + await self._peer_connection.setRemoteDescription({ + "type": "answer", + "sdp": sdp + }) + await self.on_ice_candidates_new(self.candidates_buffer) + self.candidates_buffer.clear() + + def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: + """Called when new ICE candidates are received + + @param sid: Session identifier + @param candidates_s: ICE candidates serialized + @param profile: Profile associated with the action + """ + if sid != self.sid: + log.debug( + f"ignoring peer ice candidates for {sid=} ({self.sid=})." + ) + return + candidates = json.loads(candidates_s) + aio.run(self.on_ice_candidates_new(candidates)) + + async def on_ice_candidates_new(self, candidates: dict) -> None: + """Called when new ICE canidates are received from peer + + @param candidates: Dictionary containing new ICE candidates + """ + log.debug(f"new peer candidates received: {candidates}") + if ( + self._peer_connection is None + or self._peer_connection.remoteDescription is None + ): + for media_type in ("audio", "video"): + media_candidates = candidates.get(media_type) + if media_candidates: + buffer = self.candidates_buffer[media_type] + buffer["candidates"].extend(media_candidates["candidates"]) + return + for media_type, ice_data in candidates.items(): + for candidate in ice_data["candidates"]: + candidate_sdp = self.build_ice_candidate(candidate) + try: + sdp_mline_index = self.get_sdp_mline_index(media_type) + except Exception as e: + log.warning(e) + continue + ice_candidate = window.RTCIceCandidate.new({ + "candidate": candidate_sdp, + "sdpMLineIndex": sdp_mline_index + } + ) + await self._peer_connection.addIceCandidate(ice_candidate) + + def on_track(self, event): + """New track has been received from peer + + @param event: Event associated with the new track + """ + if event.streams and event.streams[0]: + remote_stream = event.streams[0] + document["remote_video"].srcObject = remote_stream + else: + if self.remote_stream is None: + self.remote_stream = window.MediaStream.new() + document["remote_video"].srcObject = self.remote_stream + self.remote_stream.addTrack(event.track) + + document["call_btn"].classList.add("is-hidden") + document["hangup_btn"].classList.remove("is-hidden") + + def on_negotiation_needed(self, event) -> None: + log.debug(f"on_negotiation_needed {event=}") + # TODO + + async def answer_call(self, offer_sdp: str, action_id: str): + """We respond to the call""" + log.debug("answering call") + await self._create_peer_connection() + + await self._peer_connection.setRemoteDescription({ + "type": "offer", + "sdp": offer_sdp + }) + await self.on_ice_candidates_new(self.candidates_buffer) + self.candidates_buffer.clear() + await self._get_user_media() + + # Gather local ICE candidates + local_ice_data = await self._gather_ice_candidates(False) + self.local_candidates = local_ice_data["candidates"] + + await bridge.action_launch( + action_id, + json.dumps({ + "sdp": self._peer_connection.localDescription.sdp, + }) + ) + + async def make_call(self, audio: bool = True, video: bool = True) -> None: + """Start a WebRTC call + + @param audio: True if an audio flux is required + @param video: True if a video flux is required + """ + await self._create_peer_connection() + await self._get_user_media(audio, video) + await self._gather_ice_candidates(True) + callee_jid = document["callee_jid"].value + + call_data = { + "sdp": self._peer_connection.localDescription.sdp + } + log.info(f"calling {callee_jid!r}") + self.sid = await bridge.call_start( + callee_jid, + json.dumps(call_data) + ) + log.debug(f"Call SID: {self.sid}") + + async def end_call(self) -> None: + """Stop streaming and clean instance""" + document["hangup_btn"].classList.add("is-hidden") + document["call_btn"].classList.remove("is-hidden") + if self._peer_connection is None: + log.debug("There is currently no call to end.") + else: + self._peer_connection.removeEventListener("track", self.on_track) + self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed) + self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate) + self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) + + local_video = document["local_video"] + remote_video = document["remote_video"] + if local_video.srcObject: + for track in local_video.srcObject.getTracks(): + track.stop() + if remote_video.srcObject: + for track in remote_video.srcObject.getTracks(): + track.stop() + + self._peer_connection.close() + self.reset_instance() + + async def hand_up(self) -> None: + """Terminate the call""" + session_id = self.sid + await self.end_call() + await bridge.call_end( + session_id, + "" + ) + + +webrtc_call = WebRTCCall() + +document["call_btn"].bind( + "click", + lambda __: aio.run(webrtc_call.make_call()) +) +document["hangup_btn"].bind( + "click", + lambda __: aio.run(webrtc_call.hand_up()) +) + +bridge.register_signal("action_new", webrtc_call.on_action_new) +bridge.register_signal("call_accepted", webrtc_call._on_call_accepted) +bridge.register_signal("call_ended", webrtc_call._on_call_ended) +bridge.register_signal("ice_candidates_new", webrtc_call._on_ice_candidates_new) + +loading.remove_loading_screen()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/calls/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format +from twisted.internet import defer +import datetime +import time +from dateutil import tz + +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + + +name = "calls" +access = C.PAGES_ACCESS_PROFILE +template = "call/call.html"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/chat/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +from libervia.backend.core.i18n import _ +from twisted.internet import defer +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_objects +from libervia.backend.tools.common import data_format +from twisted.words.protocols.jabber import jid +from libervia.web.server.constants import Const as C +from libervia.web.server import session_iface + + +log = getLogger(__name__) + +name = "chat" +access = C.PAGES_ACCESS_PROFILE +template = "chat/chat.html" +dynamic = True + + +def parse_url(self, request): + rdata = self.get_r_data(request) + + try: + target_jid_s = self.next_path(request) + except IndexError: + # not chat jid, we redirect to jid selection page + self.page_redirect("chat_select", request) + + try: + target_jid = jid.JID(target_jid_s) + if not target_jid.user: + raise ValueError(_("invalid jid for chat (no local part)")) + except Exception as e: + log.warning( + _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid, msg=e) + ) + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + rdata["target"] = target_jid + + +@defer.inlineCallbacks +def prepare_render(self, request): + # FIXME: bug on room filtering (currently display messages from all rooms) + session = self.host.get_session_data(request, session_iface.IWebSession) + template_data = request.template_data + rdata = self.get_r_data(request) + target_jid = rdata["target"] + profile = session.profile + profile_jid = session.jid + + disco = yield self.host.bridge_call("disco_infos", target_jid.host, "", True, profile) + if "conference" in [i[0] for i in disco[1]]: + chat_type = C.CHAT_GROUP + join_ret = yield self.host.bridge_call( + "muc_join", target_jid.userhost(), "", "", profile + ) + (already_joined, + room_jid_s, + occupants, + user_nick, + room_subject, + room_statuses, + __) = join_ret + template_data["subject"] = room_subject + template_data["room_statuses"] = room_statuses + own_jid = jid.JID(room_jid_s) + own_jid.resource = user_nick + else: + chat_type = C.CHAT_ONE2ONE + own_jid = profile_jid + rdata["chat_type"] = chat_type + template_data["own_jid"] = own_jid + + self.register_signal(request, "message_new") + history = yield self.host.bridge_call( + "history_get", + profile_jid.userhost(), + target_jid.userhost(), + 20, + True, + {}, + profile, + ) + authors = {m[2] for m in history} + identities = session.identities + for author in authors: + id_raw = yield self.host.bridge_call( + "identity_get", author, [], True, profile) + identities[author] = data_format.deserialise(id_raw) + + template_data["messages"] = data_objects.Messages(history) + rdata['identities'] = identities + template_data["target_jid"] = target_jid + template_data["chat_type"] = chat_type + + +def on_data(self, request, data): + session = self.host.get_session_data(request, session_iface.IWebSession) + rdata = self.get_r_data(request) + target = rdata["target"] + data_type = data.get("type", "") + if data_type == "msg": + message = data["body"] + mess_type = ( + C.MESS_TYPE_GROUPCHAT + if rdata["chat_type"] == C.CHAT_GROUP + else C.MESS_TYPE_CHAT + ) + log.debug("message received: {}".format(message)) + self.host.bridge_call( + "message_send", + target.full(), + {"": message}, + {}, + mess_type, + "", + session.profile, + ) + else: + log.warning("unknown message type: {type}".format(type=data_type))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/chat/select/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import _ +from libervia.web.server.constants import Const as C +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from libervia.backend.tools.common import data_objects +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "chat_select" +access = C.PAGES_ACCESS_PROFILE +template = "chat/select.html" + + +@defer.inlineCallbacks +def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + rooms = template_data["rooms"] = [] + bookmarks = yield self.host.bridge_call("bookmarks_list", "muc", "all", profile) + for bm_values in list(bookmarks.values()): + for room_jid, room_data in bm_values.items(): + url = self.get_page_by_name("chat").get_url(room_jid) + rooms.append(data_objects.Room(room_jid, name=room_data.get("name"), url=url)) + rooms.sort(key=lambda r: r.name) + + +@defer.inlineCallbacks +def on_data_post(self, request): + jid_ = self.get_posted_data(request, "jid") + if "@" not in jid_: + profile = self.get_profile(request) + service = yield self.host.bridge_call("muc_get_service", "", profile) + if service: + muc_jid = jid.JID(service) + muc_jid.user = jid_ + jid_ = muc_jid.full() + else: + log.warning(_("Invalid jid received: {jid}".format(jid=jid_))) + defer.returnValue(C.POST_NO_CONFIRM) + url = self.get_page_by_name("chat").get_url(jid_) + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/embed/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +from libervia.backend.core.log import getLogger +from libervia.backend.core import exceptions +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + +name = "embed_app" +template = "embed/embed.html" + + +def parse_url(self, request): + self.get_path_args(request, ["app_name"], min_args=1) + data = self.get_r_data(request) + app_name = data["app_name"] + try: + app_data = self.vhost_root.libervia_apps[app_name] + except KeyError: + self.page_error(request, C.HTTP_BAD_REQUEST) + template_data = request.template_data + template_data['full_screen_body'] = True + try: + template_data["target_url"] = app_data["url_prefix"] + except KeyError: + raise exceptions.InternalError(f'"url_prefix" is missing for {app_name!r}')
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,52 @@ +from browser import DOMNode, document, aio +from javascript import JSON +from bridge import AsyncBridge as Bridge, BridgeException +import dialog + +bridge = Bridge() + + +async def on_delete(evt): + evt.stopPropagation() + evt.preventDefault() + target = evt.currentTarget + item_elt = DOMNode(target.closest('.item')) + item_elt.classList.add("selected_for_deletion") + item = JSON.parse(item_elt.dataset.item) + confirmed = await dialog.Confirm( + f"Event {item['name']!r} will be deleted, are you sure?", + ok_label="delete", + ).ashow() + + if not confirmed: + item_elt.classList.remove("selected_for_deletion") + return + + try: + await bridge.interest_retract("", item['interest_id']) + except BridgeException as e: + dialog.notification.show( + f"Can't remove list {item['name']!r} from personal interests: {e}", + "error" + ) + else: + print(f"{item['name']!r} removed successfuly from list of interests") + item_elt.classList.add("state_deleted") + item_elt.bind("transitionend", lambda evt: item_elt.remove()) + if item.get("creator", False): + try: + await bridge.ps_node_delete( + item['service'], + item['node'], + ) + except BridgeException as e: + dialog.notification.show( + f"Error while deleting {item['name']!r}: {e}", + "error" + ) + else: + dialog.notification.show(f"{item['name']!r} has been deleted") + + +for elt in document.select('.action_delete'): + elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/admin/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.words.protocols.jabber import jid +from libervia.backend.tools.common.template import safe +from libervia.backend.tools.common import data_format +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.log import getLogger +import time +import html +import math +import re + +name = "event_admin" +label = D_("Event Administration") +access = C.PAGES_ACCESS_PROFILE +template = "event/admin.html" +log = getLogger(__name__) +REG_EMAIL_RE = re.compile(C.REG_EMAIL_RE, re.IGNORECASE) + + +def parse_url(self, request): + self.get_path_args( + request, + ("event_service", "event_node", "event_id"), + min_args=2, + event_service="@jid", + event_id="", + ) + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + + ## Event ## + + event_service = template_data["event_service"] = data["event_service"] + event_node = template_data["event_node"] = data["event_node"] + event_id = template_data["event_id"] = data["event_id"] + profile = self.get_profile(request) + event_timestamp, event_data = await self.host.bridge_call( + "eventGet", + event_service.userhost() if event_service else "", + event_node, + event_id, + profile, + ) + try: + background_image = event_data.pop("background-image") + except KeyError: + pass + else: + template_data["dynamic_style"] = safe( + """ + html { + background-image: url("%s"); + background-size: 15em; + } + """ + % html.escape(background_image, True) + ) + template_data["event"] = event_data + invitees = await self.host.bridge_call( + "event_invitees_list", + event_data["invitees_service"], + event_data["invitees_node"], + profile, + ) + template_data["invitees"] = invitees + invitees_guests = 0 + for invitee_data in invitees.values(): + if invitee_data.get("attend", "no") == "no": + continue + try: + invitees_guests += int(invitee_data.get("guests", 0)) + except ValueError: + log.warning( + _("guests value is not valid: {invitee}").format(invitee=invitee_data) + ) + template_data["invitees_guests"] = invitees_guests + template_data["days_left"] = int( + math.ceil((event_timestamp - time.time()) / (60 * 60 * 24)) + ) + + ## Blog ## + + data["service"] = jid.JID(event_data["blog_service"]) + data["node"] = event_data["blog_node"] + data["allow_commenting"] = "simple" + + # we now need blog items, using blog common page + # this will fill the "items" template data + blog_page = self.get_page_by_name("blog_view") + await blog_page.prepare_render(self, request) + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if not profile: + log.error("got post data without profile") + self.page_error(request, C.HTTP_INTERNAL_ERROR) + type_ = self.get_posted_data(request, "type") + if type_ == "blog": + service, node, title, body, lang = self.get_posted_data( + request, ("service", "node", "title", "body", "language") + ) + + if not body.strip(): + self.page_error(request, C.HTTP_BAD_REQUEST) + data = {"content": body} + if title: + data["title"] = title + if lang: + data["language"] = lang + try: + comments = bool(self.get_posted_data(request, "comments").strip()) + except KeyError: + pass + else: + if comments: + data["allow_comments"] = True + + try: + await self.host.bridge_call( + "mb_send", service, node, data_format.serialise(data), profile) + except Exception as e: + if "forbidden" in str(e): + self.page_error(request, C.HTTP_FORBIDDEN) + else: + raise e + elif type_ == "event": + service, node, event_id, jids, emails = self.get_posted_data( + request, ("service", "node", "event_id", "jids", "emails") + ) + for invitee_jid_s in jids.split(): + try: + invitee_jid = jid.JID(invitee_jid_s) + except RuntimeError: + log.warning( + _("this is not a valid jid: {jid}").format(jid=invitee_jid_s) + ) + continue + await self.host.bridge_call( + "event_invite", invitee_jid.userhost(), service, node, event_id, profile + ) + for email_addr in emails.split(): + if not REG_EMAIL_RE.match(email_addr): + log.warning( + _("this is not a valid email address: {email}").format( + email=email_addr + ) + ) + continue + await self.host.bridge_call( + "event_invite_by_email", + service, + node, + event_id, + email_addr, + {}, + "", + "", + "", + "", + "", + "", + profile, + ) + + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/new/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.internet import defer +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import date_utils + +"""creation of new events""" + +name = "event_new" +access = C.PAGES_ACCESS_PROFILE +template = "event/create.html" +log = getLogger(__name__) + + +@defer.inlineCallbacks +def on_data_post(self, request): + request_data = self.get_r_data(request) + profile = self.get_profile(request) + title, location, body, date, main_img, bg_img = self.get_posted_data( + request, ("name", "location", "body", "date", "main_image", "bg_image") + ) + timestamp = date_utils.date_parse(date) + data = {"name": title, "description": body, "location": location} + + for value, var in ((main_img, "image"), (bg_img, "background-image")): + value = value.strip() + if not value: + continue + if not value.startswith("http"): + self.page_error(request, C.HTTP_BAD_REQUEST) + data[var] = value + data["register"] = C.BOOL_TRUE + node = yield self.host.bridge_call("event_create", timestamp, data, "", "", "", profile) + log.info("Event node created at {node}".format(node=node)) + + request_data["post_redirect_page"] = (self.get_page_by_name("event_admin"), "@", node) + defer.returnValue(C.POST_NO_CONFIRM)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format +from twisted.internet import defer + +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + + +name = "events" +access = C.PAGES_ACCESS_PUBLIC +template = "event/overview.html" + + +async def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + template_data["url_event_new"] = self.get_sub_page_url(request, "event_new") + if profile is not None: + try: + events = data_format.deserialise( + await self.host.bridge_call("events_get", "", "", [], "", profile), + type_check=list + ) + except Exception as e: + log.warning(_("Can't get events list for {profile}: {reason}").format( + profile=profile, reason=e)) + else: + own_events = [] + other_events = [] + for event in events: + if C.bool(event.get("creator", C.BOOL_FALSE)): + own_events.append(event) + event["url"] = self.get_sub_page_url( + request, + "event_admin", + ) + else: + other_events.append(event) + event["url"] = self.get_sub_page_url( + request, + "event_rsvp", + ) + if "thumb_url" not in event and "image" in event: + event["thumb_url"] = event["image"] + + template_data["events"] = own_events + other_events
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/rsvp/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _, D_ +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common.template import safe +import time +import html + +"""creation of new events""" + +name = "event_rsvp" +label = D_("Event Invitation") +access = C.PAGES_ACCESS_PROFILE +template = "event/invitation.html" +log = getLogger(__name__) + + +def parse_url(self, request): + self.get_path_args( + request, + ("event_service", "event_node", "event_id"), + min_args=2, + event_service="@jid", + event_id="", + ) + + +@defer.inlineCallbacks +def prepare_render(self, request): + template_data = request.template_data + data = self.get_r_data(request) + profile = self.get_profile(request) + + ## Event ## + + event_service = data["event_service"] + event_node = data["event_node"] + event_id = data["event_id"] + event_timestamp, event_data = yield self.host.bridge_call( + "eventGet", + event_service.userhost() if event_service else "", + event_node, + event_id, + profile, + ) + try: + background_image = event_data.pop("background-image") + except KeyError: + pass + else: + template_data["dynamic_style"] = safe( + """ + html { + background-image: url("%s"); + background-size: 15em; + } + """ + % html.escape(background_image, True) + ) + template_data["event"] = event_data + event_invitee_data = yield self.host.bridge_call( + "event_invitee_get", + event_data["invitees_service"], + event_data["invitees_node"], + '', + profile, + ) + template_data["invitee"] = event_invitee_data + template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24)) + + ## Blog ## + + data["service"] = jid.JID(event_data["blog_service"]) + data["node"] = event_data["blog_node"] + data["allow_commenting"] = "simple" + + # we now need blog items, using blog common page + # this will fill the "items" template data + blog_page = self.get_page_by_name("blog_view") + yield blog_page.prepare_render(self, request) + + +@defer.inlineCallbacks +def on_data_post(self, request): + type_ = self.get_posted_data(request, "type") + if type_ == "comment": + blog_page = self.get_page_by_name("blog_view") + yield blog_page.on_data_post(self, request) + elif type_ == "attendance": + profile = self.get_profile(request) + service, node, attend, guests = self.get_posted_data( + request, ("service", "node", "attend", "guests") + ) + data = {"attend": attend, "guests": guests} + yield self.host.bridge_call("event_invitee_set", service, node, data, profile) + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/events/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from libervia.web.server import session_iface +from libervia.backend.tools.common import uri +from libervia.backend.tools.common.template import safe +import time +import html +from libervia.backend.core.log import getLogger + +name = "event_view" +access = C.PAGES_ACCESS_PROFILE +template = "event/invitation.html" +log = getLogger(__name__) + + +@defer.inlineCallbacks +def prepare_render(self, request): + template_data = request.template_data + guest_session = self.host.get_session_data(request, session_iface.IWebGuestSession) + try: + event_uri = guest_session.data["event_uri"] + except KeyError: + log.warning(_("event URI not found, can't render event page")) + self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE) + + data = self.get_r_data(request) + + ## Event ## + + event_uri_data = uri.parse_xmpp_uri(event_uri) + if event_uri_data["type"] != "pubsub": + self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE) + + event_service = template_data["event_service"] = jid.JID(event_uri_data["path"]) + event_node = template_data["event_node"] = event_uri_data["node"] + event_id = template_data["event_id"] = event_uri_data.get("item", "") + profile = self.get_profile(request) + event_timestamp, event_data = yield self.host.bridge_call( + "eventGet", event_service.userhost(), event_node, event_id, profile + ) + try: + background_image = event_data.pop("background-image") + except KeyError: + pass + else: + template_data["dynamic_style"] = safe( + """ + html { + background-image: url("%s"); + background-size: 15em; + } + """ + % html.escape(background_image, True) + ) + template_data["event"] = event_data + event_invitee_data = yield self.host.bridge_call( + "event_invitee_get", + event_data["invitees_service"], + event_data["invitees_node"], + '', + profile, + ) + template_data["invitee"] = event_invitee_data + template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24)) + + ## Blog ## + + data["service"] = jid.JID(event_data["blog_service"]) + data["node"] = event_data["blog_node"] + data["allow_commenting"] = "simple" + + # we now need blog items, using blog common page + # this will fill the "items" template data + blog_page = self.get_page_by_name("blog_view") + yield blog_page.prepare_render(self, request) + + +@defer.inlineCallbacks +def on_data_post(self, request): + type_ = self.get_posted_data(request, "type") + if type_ == "comment": + blog_page = self.get_page_by_name("blog_view") + yield blog_page.on_data_post(self, request) + elif type_ == "attendance": + profile = self.get_profile(request) + service, node, attend, guests = self.get_posted_data( + request, ("service", "node", "attend", "guests") + ) + data = {"attend": attend, "guests": guests} + yield self.host.bridge_call("event_invitee_set", service, node, data, profile) + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/files/list/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import uri +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.web.server.constants import Const as C +from libervia.web.server import session_iface +from libervia.web.server import pages_tools + +log = getLogger(__name__) +"""files handling pages""" + +name = "files_list" +access = C.PAGES_ACCESS_PROFILE +template = "file/overview.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="") + + +def add_breadcrumb(self, request, breadcrumbs): + data = self.get_r_data(request) + breadcrumbs.append({ + "label": data["service"], + "url": self.get_url(data["service"].full()), + "icon": "server", + }) + for idx, p in enumerate(data["path"]): + breadcrumbs.append({ + "label": p, + "url": self.get_url(data["service"].full(), *data["path"][:idx+1]), + "icon": "folder-open-empty", + }) + + +async def prepare_render(self, request): + data = self.get_r_data(request) + thumb_limit = data.get("thumb_limit", 400) + template_data = request.template_data + service, path_elts = data["service"], data["path"] + path = Path('/', *path_elts) + profile = self.get_profile(request) or C.SERVICE_PROFILE + session_data = self.host.get_session_data( + request, session_iface.IWebSession + ) + + try: + files_data = await self.host.bridge_call( + "fis_list", service.full(), str(path), {}, profile) + except BridgeException as e: + if e.condition == 'item-not-found': + log.debug( + f'"item-not-found" received for {path} at {service}, this may indicate ' + f'that the location is new') + files_data = [] + else: + raise e + for file_data in files_data: + try: + extra_raw = file_data["extra"] + except KeyError: + pass + else: + file_data["extra"] = json.loads(extra_raw) if extra_raw else {} + dir_path = path_elts + [file_data["name"]] + if file_data["type"] == C.FILE_TYPE_DIRECTORY: + page = self + elif file_data["type"] == C.FILE_TYPE_FILE: + page = self.get_page_by_name("files_view") + + # we set URL for the last thumbnail which has a size below thumb_limit + try: + thumbnails = file_data["extra"]["thumbnails"] + thumb = thumbnails[0] + for thumb_data in thumbnails: + if thumb_data["size"][0] > thumb_limit: + break + thumb = thumb_data + file_data["thumb_url"] = ( + thumb.get("url") + or os.path.join(session_data.cache_dir, thumb["filename"]) + ) + except (KeyError, IndexError): + pass + else: + raise ValueError( + "unexpected file type: {file_type}".format(file_type=file_data["type"]) + ) + file_data["url"] = page.get_url(service.full(), *dir_path) + + ## comments ## + comments_url = file_data.get("comments_url") + if comments_url: + parsed_url = uri.parse_xmpp_uri(comments_url) + comments_service = file_data["comments_service"] = parsed_url["path"] + comments_node = file_data["comments_node"] = parsed_url["node"] + try: + comments_count = file_data["comments_count"] = int( + file_data["comments_count"] + ) + except KeyError: + comments_count = None + if comments_count and data.get("retrieve_comments", False): + file_data["comments"] = await pages_tools.retrieve_comments( + self, comments_service, comments_node, profile=profile + ) + + # parent dir affiliation + # TODO: some caching? What if affiliation changes? + + try: + affiliations = await self.host.bridge_call( + "fis_affiliations_get", service.full(), "", str(path), profile + ) + except BridgeException as e: + if e.condition == 'item-not-found': + log.debug( + f'"item-not-found" received for {path} at {service}, this may indicate ' + f'that the location is new') + # FIXME: Q&D handling of empty dir (e.g. new directory/photos album) + affiliations = { + session_data.jid.userhost(): "owner" + } + if e.condition == "service-unavailable": + affiliations = {} + else: + raise e + + directory_affiliation = affiliations.get(session_data.jid.userhost()) + if directory_affiliation == "owner": + # we need to transtype dict items to str because with some bridges (D-Bus) + # we have a specific type which can't be exposed + self.expose_to_scripts( + request, + affiliations={str(e): str(a) for e, a in affiliations.items()} + ) + + template_data["directory_affiliation"] = directory_affiliation + template_data["files_data"] = files_data + template_data["path"] = path + self.expose_to_scripts( + request, + directory_affiliation=str(directory_affiliation), + files_service=service.full(), + files_path=str(path), + ) + if path_elts: + template_data["parent_url"] = self.get_url(service.full(), *path_elts[:-1])
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/files/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.words.protocols.jabber import jid +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) +"""files handling pages""" + +name = "files" +access = C.PAGES_ACCESS_PROFILE +template = "file/discover.html" + + +async def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + namespace = self.host.ns_map["fis"] + entities_services, entities_own, entities_roster = await self.host.bridge_call( + "disco_find_by_features", [namespace], [], False, True, True, True, False, profile + ) + tpl_service_entities = template_data["disco_service_entities"] = {} + tpl_own_entities = template_data["disco_own_entities"] = {} + tpl_roster_entities = template_data["disco_roster_entities"] = {} + entities_url = template_data["entities_url"] = {} + + # we store identities in dict of dict using category and type as keys + # this way it's easier to test category in the template + for tpl_entities, entities_map in ( + (tpl_service_entities, entities_services), + (tpl_own_entities, entities_own), + (tpl_roster_entities, entities_roster), + ): + for entity_str, entity_ids in entities_map.items(): + entity_jid = jid.JID(entity_str) + tpl_entities[entity_jid] = identities = {} + for cat, type_, name in entity_ids: + identities.setdefault(cat, {}).setdefault(type_, []).append(name) + entities_url[entity_jid] = self.get_page_by_name("files_list").get_url( + entity_jid.full() + ) + + +def on_data_post(self, request): + jid_str = self.get_posted_data(request, "jid") + try: + jid_ = jid.JID(jid_str) + except RuntimeError: + self.page_error(request, C.HTTP_BAD_REQUEST) + url = self.get_page_by_name("files_list").get_url(jid_.full()) + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/files/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from twisted.web import static +from libervia.web.server.utils import ProgressHandler +import tempfile +import os +import os.path +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) +"""files handling pages""" + +name = "files_view" +access = C.PAGES_ACCESS_PROFILE + + +def parse_url(self, request): + self.get_path_args(request, ["service", "*path"], min_args=2, service="jid", path="") + + +def cleanup(__, tmp_dir, dest_path): + try: + os.unlink(dest_path) + except OSError: + log.warning(_("Can't remove temporary file {path}").format(path=dest_path)) + try: + os.rmdir(tmp_dir) + except OSError: + log.warning(_("Can't remove temporary directory {path}").format(path=tmp_dir)) + + +async def render(self, request): + data = self.get_r_data(request) + profile = self.get_profile(request) + service, path_elts = data["service"], data["path"] + basename = path_elts[-1] + dir_elts = path_elts[:-1] + dir_path = "/".join(dir_elts) + tmp_dir = tempfile.mkdtemp() + dest_path = os.path.join(tmp_dir, basename) + request.notifyFinish().addCallback(cleanup, tmp_dir, dest_path) + progress_id = await self.host.bridge_call( + "file_jingle_request", + service.full(), + dest_path, + basename, + "", + "", + {"path": dir_path}, + profile, + ) + log.debug("file requested") + await ProgressHandler(self.host, progress_id, profile).register() + log.debug("file downloaded") + self.delegate_to_resource(request, static.File(dest_path))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/list/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from libervia.backend.core.log import getLogger +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import uri as xmpp_uri + +log = getLogger(__name__) +import json + +"""forum handling pages""" + +name = "forums" +access = C.PAGES_ACCESS_PUBLIC +template = "forum/overview.html" + + +def parse_url(self, request): + self.get_path_args( + request, + ["service", "node", "forum_key"], + service="@jid", + node="@", + forum_key="", + ) + + +def add_breadcrumb(self, request, breadcrumbs): + # we don't want breadcrumbs here as long as there is no forum discovery + # because it will be the landing page for forums activity until then + pass + + +def get_links(self, forums): + for forum in forums: + try: + uri = forum["uri"] + except KeyError: + pass + else: + uri = xmpp_uri.parse_xmpp_uri(uri) + service = uri["path"] + node = uri["node"] + forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node) + if "sub-forums" in forum: + get_links(self, forum["sub-forums"]) + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node, key = data["service"], data["node"], data["forum_key"] + profile = self.get_profile(request) or C.SERVICE_PROFILE + + try: + forums_raw = await self.host.bridge_call( + "forums_get", service.full() if service else "", node, key, profile + ) + except Exception as e: + log.warning(_("Can't retrieve forums: {msg}").format(msg=e)) + forums = [] + else: + forums = json.loads(forums_raw) + get_links(self, forums) + + template_data["forums"] = forums
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,3 @@ + +def prepare_render(self, request): + self.page_redirect("forums", request, skip_parse_url=False)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/topics/new/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,5 @@ +import editor + + +editor.set_form_autosave("forum_topic_edit") +editor.BlogEditor("forum_topic_edit")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/topics/new/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import D_ +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "forum_topic_new" +label = D_("New Topic") +access = C.PAGES_ACCESS_PROFILE +template = "blog/publish.html" + + +async def prepare_render(self, request): + template_data = request.template_data + template_data.update({ + "post_form_id": "forum_topic_edit", + "publish_title": D_("New Forum Topic"), + "title_label": D_("Topic"), + "title_required": True, + "body_label": D_("Message"), + "no_tabs": True, + }) + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if profile is None: + self.page_error(request, C.HTTP_FORBIDDEN) + rdata = self.get_r_data(request) + service = rdata["service"].full() if rdata["service"] else "" + node = rdata["node"] + title, body = self.get_posted_data(request, ("title", "body")) + title = title.strip() + body = body.strip() + if not title or not body: + self.page_error(request, C.HTTP_BAD_REQUEST) + topic_data = {"title": title, "content": body} + try: + await self.host.bridge_call( + "forum_topic_create", service, node, topic_data, profile + ) + except Exception as e: + if "forbidden" in str(e): + self.page_error(request, 401) + else: + raise e + + rdata["post_redirect_page"] = (self.get_page_by_name("forum_topics"), service, node)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/topics/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import uri as xmpp_uri +from libervia.backend.tools.common import data_format +from libervia.web.server import session_iface + +log = getLogger(__name__) + +name = "forum_topics" +label = D_("Forum Topics") +access = C.PAGES_ACCESS_PUBLIC +template = "forum/view_topics.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node"], 2, service="jid") + + +def add_breadcrumb(self, request, breadcrumbs): + data = self.get_r_data(request) + breadcrumbs.append({ + "label": label, + "url": self.get_url(data["service"].full(), data["node"]) + }) + + +async def prepare_render(self, request): + profile = self.get_profile(request) or C.SERVICE_PROFILE + data = self.get_r_data(request) + service, node = data["service"], data["node"] + request.template_data.update({"service": service, "node": node}) + template_data = request.template_data + page_max = data.get("page_max", 20) + extra = self.get_pubsub_extra(request, page_max=page_max) + topics, metadata = await self.host.bridge_call( + "forum_topics_get", + service.full(), + node, + extra, + profile + ) + metadata = data_format.deserialise(metadata) + self.set_pagination(request, metadata) + identities = self.host.get_session_data( + request, session_iface.IWebSession + ).identities + for topic in topics: + parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"]) + author = topic["author"] + topic["http_uri"] = self.get_page_by_name("forum_view").get_url( + parsed_uri["path"], parsed_uri["node"] + ) + if author not in identities: + id_raw = await self.host.bridge_call( + "identity_get", author, [], True, profile + ) + identities[topic["author"]] = data_format.deserialise(id_raw) + + template_data["topics"] = topics + template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new") + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if profile is None: + self.page_error(request, C.HTTP_FORBIDDEN) + type_ = self.get_posted_data(request, "type") + if type_ == "new_topic": + service, node, title, body = self.get_posted_data( + request, ("service", "node", "title", "body") + ) + + if not title or not body: + self.page_error(request, C.HTTP_BAD_REQUEST) + topic_data = {"title": title, "content": body} + try: + await self.host.bridge_call( + "forum_topic_create", service, node, topic_data, profile + ) + except Exception as e: + if "forbidden" in str(e): + self.page_error(request, 401) + else: + raise e + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format + +log = getLogger(__name__) + +name = "forum_view" +label = D_("View") +access = C.PAGES_ACCESS_PUBLIC +template = "forum/view.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node"], 2, service="jid") + + +async def prepare_render(self, request): + data = self.get_r_data(request) + data["show_comments"] = False + blog_page = self.get_page_by_name("blog_view") + request.args[b"before"] = [b""] + request.args[b"reverse"] = [b"1"] + await blog_page.prepare_render(self, request) + request.template_data["login_url"] = self.get_page_redirect_url(request) + + +async def on_data_post(self, request): + profile = self.get_profile(request) + if profile is None: + self.page_error(request, C.HTTP_FORBIDDEN) + type_ = self.get_posted_data(request, "type") + if type_ == "comment": + service, node, body = self.get_posted_data(request, ("service", "node", "body")) + + if not body: + self.page_error(request, C.HTTP_BAD_REQUEST) + mb_data = {"content_rich": body} + try: + await self.host.bridge_call( + "mb_send", service, node, data_format.serialise(mb_data), profile) + except Exception as e: + if "forbidden" in str(e): + self.page_error(request, 401) + else: + raise e + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/g/e/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + + +redirect = "event_view"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/g/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.web.server import session_iface +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +access = C.PAGES_ACCESS_PUBLIC +template = "invitation/welcome.html" + + +async def parse_url(self, request): + """check invitation id in URL and start session if needed + + if a session already exists for an other guest/profile, it will be purged + """ + try: + invitation_id = self.next_path(request) + except IndexError: + self.page_error(request) + + web_session, guest_session = self.host.get_session_data( + request, session_iface.IWebSession, session_iface.IWebGuestSession + ) + current_id = guest_session.id + + if current_id is not None and current_id != invitation_id: + log.info( + _( + "killing guest session [{old_id}] because it is connecting with an other ID [{new_id}]" + ).format(old_id=current_id, new_id=invitation_id) + ) + self.host.purge_session(request) + web_session, guest_session = self.host.get_session_data( + request, session_iface.IWebSession, session_iface.IWebGuestSession + ) + current_id = None # FIXME: id not reset here + profile = None + + profile = web_session.profile + if profile is not None and current_id is None: + log.info( + _( + "killing current profile session [{profile}] because a guest id is used" + ).format(profile=profile) + ) + self.host.purge_session(request) + web_session, guest_session = self.host.get_session_data( + request, session_iface.IWebSession, session_iface.IWebGuestSession + ) + profile = None + + if current_id is None: + log.debug(_("checking invitation [{id}]").format(id=invitation_id)) + try: + data = await self.host.bridge_call("invitation_get", invitation_id) + except Exception: + self.page_error(request, C.HTTP_FORBIDDEN) + else: + guest_session.id = invitation_id + guest_session.data = data + else: + data = guest_session.data + + if profile is None: + log.debug(_("connecting profile [{}]").format(profile)) + # we need to connect the profile + profile = data["guest_profile"] + password = data["password"] + try: + await self.host.connect(request, profile, password) + except Exception as e: + log.warning(_("Can't connect profile: {msg}").format(msg=e)) + # FIXME: no good error code correspond + # maybe use a custom one? + self.page_error(request, code=C.HTTP_SERVICE_UNAVAILABLE) + + log.info( + _( + "guest session started, connected with profile [{profile}]".format( + profile=profile + ) + ) + ) + + # we copy data useful in templates + template_data = request.template_data + template_data["norobots"] = True + if "name" in data: + template_data["name"] = data["name"] + if "language" in data: + template_data["locale"] = data["language"] + +def handle_event_interest(self, interest): + if C.bool(interest.get("creator", C.BOOL_FALSE)): + page_name = "event_admin" + else: + page_name = "event_rsvp" + + interest["url"] = self.get_page_by_name(page_name).get_url( + interest.get("service", ""), + interest.get("node", ""), + interest.get("item"), + ) + + if "thumb_url" not in interest and "image" in interest: + interest["thumb_url"] = interest["image"] + +def handle_fis_interest(self, interest): + path = interest.get('path', '') + path_args = [p for p in path.split('/') if p] + subtype = interest.get('subtype') + + if subtype == 'files': + page_name = "files_view" + elif interest.get('subtype') == 'photos': + page_name = "photos_album" + else: + log.warning("unknown interest subtype: {subtype}".format(subtype=subtype)) + return False + + interest["url"] = self.get_page_by_name(page_name).get_url( + interest['service'], *path_args) + +async def prepare_render(self, request): + template_data = request.template_data + profile = self.get_profile(request) + + # interests + template_data['interests_map'] = interests_map = {} + try: + interests = await self.host.bridge_call( + "interests_list", "", "", "", profile) + except Exception: + log.warning(_("Can't get interests list for {profile}").format( + profile=profile)) + else: + # we only want known interests (photos and events for now) + # this dict map namespaces of interest to a callback which can manipulate + # the data. If it returns False, the interest is skipped + ns_data = {} + + for short_name, cb in (('event', handle_event_interest), + ('fis', handle_fis_interest), + ): + try: + namespace = self.host.ns_map[short_name] + except KeyError: + pass + else: + ns_data[namespace] = (cb, short_name) + + for interest in interests: + namespace = interest.get('namespace') + if namespace not in ns_data: + continue + cb, short_name = ns_data[namespace] + if cb(self, interest) == False: + continue + key = interest.get('subtype', short_name) + interests_map.setdefault(key, []).append(interest) + + # main URI + guest_session = self.host.get_session_data(request, session_iface.IWebGuestSession) + main_uri = guest_session.data.get("event_uri") + if main_uri: + include_url = self.get_page_path_from_uri(main_uri) + if include_url is not None: + template_data["include_url"] = include_url
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,52 @@ +from browser import DOMNode, document, aio +from javascript import JSON +from bridge import AsyncBridge as Bridge, BridgeException +import dialog + +bridge = Bridge() + + +async def on_delete(evt): + evt.stopPropagation() + evt.preventDefault() + target = evt.currentTarget + item_elt = DOMNode(target.closest('.item')) + item_elt.classList.add("selected_for_deletion") + item = JSON.parse(item_elt.dataset.item) + confirmed = await dialog.Confirm( + f"List {item['name']!r} will be deleted, are you sure?", + ok_label="delete", + ).ashow() + + if not confirmed: + item_elt.classList.remove("selected_for_deletion") + return + + try: + await bridge.interest_retract("", item['id']) + except BridgeException as e: + dialog.notification.show( + f"Can't remove list {item['name']!r} from personal interests: {e}", + "error" + ) + else: + print(f"{item['name']!r} removed successfuly from list of interests") + item_elt.classList.add("state_deleted") + item_elt.bind("transitionend", lambda evt: item_elt.remove()) + if item.get("creator", False): + try: + await bridge.ps_node_delete( + item['service'], + item['node'], + ) + except BridgeException as e: + dialog.notification.show( + f"Error while deleting {item['name']!r}: {e}", + "error" + ) + else: + dialog.notification.show(f"{item['name']!r} has been deleted") + + +for elt in document.select('.action_delete'): + elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/create/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "list_create" +access = C.PAGES_ACCESS_PROFILE +template = "list/create.html" + + +def parse_url(self, request): + self.get_path_args(request, ["template_id"]) + data = self.get_r_data(request) + if data["template_id"]: + self.http_redirect( + request, + self.get_page_by_name("list_create_from_tpl").get_url(data["template_id"]) + ) + + +async def prepare_render(self, request): + template_data = request.template_data + profile = self.get_profile(request) + tpl_raw = await self.host.bridge_call( + "list_templates_names_get", + "", + profile, + ) + lists_templates = data_format.deserialise(tpl_raw, type_check=list) + template_data["icons_names"] = {tpl['icon'] for tpl in lists_templates} + template_data["lists_templates"] = [ + { + "icon": tpl["icon"], + "name": tpl["name"], + "url": self.get_url(tpl["id"]), + } + for tpl in lists_templates + ]
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/create_from_tpl/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger +from libervia.backend.core.i18n import D_ +from libervia.backend.core import exceptions +from libervia.web.server.constants import Const as C +from libervia.frontends.bridge.bridge_frontend import BridgeException + +log = getLogger(__name__) + +name = "list_create_from_tpl" +access = C.PAGES_ACCESS_PROFILE +template = "list/create_from_template.html" + + +def parse_url(self, request): + self.get_path_args(request, ["template_id"]) + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_id = data["template_id"] + if not template_id: + self.page_error(request, C.HTTP_BAD_REQUEST) + + template_data = request.template_data + profile = self.get_profile(request) + tpl_raw = await self.host.bridge_call( + "list_template_get", + template_id, + "", + profile, + ) + template = data_format.deserialise(tpl_raw) + template['id'] = template_id + template_data["list_template"] = template + +async def on_data_post(self, request): + data = self.get_r_data(request) + template_id = data['template_id'] + name, access = self.get_posted_data(request, ('name', 'access')) + if access == 'private': + access_model = 'whitelist' + elif access == 'public': + access_model = 'open' + else: + log.warning(f"Unknown access for template creation: {access}") + self.page_error(request, C.HTTP_BAD_REQUEST) + profile = self.get_profile(request) + try: + service, node = await self.host.bridge_call( + "list_template_create", template_id, name, access_model, profile + ) + except BridgeException as e: + if e.condition == "conflict": + raise exceptions.DataError(D_("A list with this name already exists")) + else: + log.error(f"Can't create list from template: {e}") + raise e + data["post_redirect_page"] = ( + self.get_page_by_name("lists"), + service, + node or "@", + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/edit/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from twisted.internet import defer +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "list_edit" +access = C.PAGES_ACCESS_PROFILE +template = "list/edit.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@") + data = self.get_r_data(request) + if data["item_id"] is None: + log.warning(_("no list item id specified")) + self.page_error(request, C.HTTP_BAD_REQUEST) + +@defer.inlineCallbacks +def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node, item_id = ( + data.get("service", ""), + data.get("node", ""), + data["item_id"], + ) + profile = self.get_profile(request) + + # we don't ignore "author" below to keep it when a list item is edited + # by node owner/admin and "consistent publisher" is activated + ignore = ( + "publisher", + "author", + "author_jid", + "author_email", + "created", + "updated", + "comments_uri", + ) + list_raw = yield self.host.bridge_call( + "list_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [item_id], + "", + data_format.serialise({}), + profile, + ) + list_items, metadata = data_format.deserialise(list_raw, type_check=list) + list_item = [template_xmlui.create(self.host, x, ignore=ignore) for x in list_items][0] + + try: + # small trick to get a one line text input instead of the big textarea + list_item.widgets["labels"].type = "string" + list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace( + "\n", ", " + ) + except KeyError: + pass + + # for now we don't have XHTML editor, so we'll go with a TextBox and a convertion + # to a text friendly syntax using markdown + wid = list_item.widgets['body'] + if wid.type == "xhtmlbox": + wid.type = "textbox" + wid.value = yield self.host.bridge_call( + "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", + False, profile) + + template_data["new_list_item_xmlui"] = list_item + + +async def on_data_post(self, request): + data = self.get_r_data(request) + service = data["service"] + node = data["node"] + item_id = data["item_id"] + posted_data = self.get_all_posted_data(request) + if not posted_data["title"] or not posted_data["body"]: + self.page_error(request, C.HTTP_BAD_REQUEST) + try: + posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] + except (KeyError, IndexError): + pass + profile = self.get_profile(request) + + # we convert back body to XHTML + body = await self.host.bridge_call( + "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, + False, profile) + posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, + body=body)] + + extra = {'update': True} + await self.host.bridge_call( + "list_set", service.full(), node, posted_data, "", item_id, + data_format.serialise(extra), profile + ) + data["post_redirect_page"] = ( + self.get_page_by_name("list_view"), + service.full(), + node or "@", + item_id + ) + return C.POST_NO_CONFIRM
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/new/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from libervia.backend.tools.common import template_xmlui +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + + +name = "list_new" +access = C.PAGES_ACCESS_PROFILE +template = "list/create_item.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node"], service="jid", node="@") + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node = data.get("service", ""), data.get("node", "") + profile = self.get_profile(request) + schema = await self.host.bridge_call("list_schema_get", service.full(), node, profile) + data["schema"] = schema + # following fields are handled in backend + ignore = ( + "author", + "author_jid", + "author_email", + "created", + "updated", + "comments_uri", + "status", + "milestone", + "priority", + ) + xmlui_obj = template_xmlui.create(self.host, schema, ignore=ignore) + try: + # small trick to get a one line text input instead of the big textarea + xmlui_obj.widgets["labels"].type = "string" + except KeyError: + pass + + try: + wid = xmlui_obj.widgets['body'] + except KeyError: + pass + else: + if wid.type == "xhtmlbox": + # same as for list_edit, we have to convert for now + wid.type = "textbox" + wid.value = await self.host.bridge_call( + "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", + False, profile) + template_data["new_list_item_xmlui"] = xmlui_obj + + +async def on_data_post(self, request): + data = self.get_r_data(request) + service = data["service"] + node = data["node"] + posted_data = self.get_all_posted_data(request) + if (("title" in posted_data and not posted_data["title"]) + or ("body" in posted_data and not posted_data["body"])): + self.page_error(request, C.HTTP_BAD_REQUEST) + try: + posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] + except (KeyError, IndexError): + pass + profile = self.get_profile(request) + + # we convert back body to XHTML + if "body" in posted_data: + body = await self.host.bridge_call( + "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, + False, profile) + posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, + body=body)] + + + await self.host.bridge_call( + "list_set", service.full(), node, posted_data, "", "", "", profile + ) + # we don't want to redirect to creation page on success, but to list overview + data["post_redirect_page"] = ( + self.get_page_by_name("lists"), + service.full(), + node or "@", + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from twisted.words.protocols.jabber import jid +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format + +log = getLogger(__name__) + +name = "lists_disco" +label = D_("Lists Discovery") +access = C.PAGES_ACCESS_PUBLIC +template = "list/discover.html" + +async def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + template_data["url_list_create"] = self.get_page_by_name("list_create").url + lists_directory_config = self.host.options["lists_directory_json"] + lists_directory = request.template_data["lists_directory"] = [] + + if lists_directory_config: + try: + for list_data in lists_directory_config: + service = list_data["service"] + node = list_data["node"] + name = list_data["name"] + url = self.get_page_by_name("lists").get_url(service, node) + lists_directory.append({"name": name, "url": url, "from_config": True}) + except KeyError as e: + log.warning("Missing field in lists_directory_json: {msg}".format(msg=e)) + except Exception as e: + log.warning("Can't decode lists directory: {msg}".format(msg=e)) + + if profile is not None: + try: + lists_list_raw = await self.host.bridge_call("lists_list", "", "", profile) + except Exception as e: + log.warning( + _("Can't get list of registered lists for {profile}: {reason}") + .format(profile=profile, reason=e) + ) + else: + lists_list = data_format.deserialise(lists_list_raw, type_check=list) + for list_data in lists_list: + service = list_data["service"] + node = list_data["node"] + list_data["url"] = self.get_page_by_name("lists").get_url(service, node) + list_data["from_config"] = False + lists_directory.append(list_data) + + icons_names = set() + for list_data in lists_directory: + try: + icons_names.add(list_data['icon_name']) + except KeyError: + pass + if icons_names: + template_data["icons_names"] = icons_names + + +def on_data_post(self, request): + jid_str = self.get_posted_data(request, "jid") + try: + jid_ = jid.JID(jid_str) + except RuntimeError: + self.page_error(request, C.HTTP_BAD_REQUEST) + # for now we just use default node + url = self.get_page_by_name("lists").get_url(jid_.full(), "@") + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/view/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,133 @@ +from browser import window, document, aio, bind +from invitation import InvitationManager +from javascript import JSON +from bridge import Async as Bridge, BridgeException +import dialog + + +bridge = Bridge() +lists_ns = window.lists_ns +pubsub_service = window.pubsub_service +pubsub_node = window.pubsub_node +list_type = window.list_type +try: + affiliations = window.affiliations.to_dict() +except AttributeError: + pass + +@bind("#button_manage", "click") +def manage_click(evt): + evt.stopPropagation() + evt.preventDefault() + pubsub_data = { + "namespace": lists_ns, + "service": pubsub_service, + "node": pubsub_node + } + try: + name = pubsub_node.split('_', 1)[1] + except IndexError: + pass + else: + name = name.strip() + if name: + pubsub_data['name'] = name + manager = InvitationManager("pubsub", pubsub_data) + manager.attach(affiliations=affiliations) + + +async def on_delete(evt): + item_elt = evt.target.closest(".item") + if item_elt is None: + dialog.notification.show( + "Can't find parent item element", + level="error" + ) + return + item_elt.classList.add("selected_for_deletion") + item = JSON.parse(item_elt.dataset.item) + confirmed = await dialog.Confirm( + f"{item['name']!r} will be deleted, are you sure?", + ok_label="delete", + ok_color="danger", + ).ashow() + item_elt.classList.remove("selected_for_deletion") + if confirmed: + try: + await bridge.ps_item_retract(pubsub_service, pubsub_node, item["id"], True) + except Exception as e: + dialog.notification.show( + f"Can't delete list item: {e}", + level="error" + ) + else: + dialog.notification.show("list item deleted successfuly") + item_elt.remove() + + +async def on_next_state(evt): + """Update item with next state + + Only used with grocery list at the moment + """ + evt.stopPropagation() + evt.preventDefault() + # FIXME: states are currently hardcoded, it would be better to use schema + item_elt = evt.target.closest(".item") + if item_elt is None: + dialog.notification.show( + "Can't find parent item element", + level="error" + ) + return + item = JSON.parse(item_elt.dataset.item) + try: + status = item["status"] + except (KeyError, IndexError) as e: + dialog.notification.show( + f"Can't get item status: {e}", + level="error" + ) + status = "to_buy" + if status == "to_buy": + item["status"] = "bought" + class_update_method = item_elt.classList.add + checked = True + elif status == "bought": + item["status"] = "to_buy" + checked = False + class_update_method = item_elt.classList.remove + else: + dialog.notification.show( + f"unexpected item status: {status!r}", + level="error" + ) + return + item_elt.dataset.item = JSON.stringify(item) + try: + await bridge.list_set( + pubsub_service, + pubsub_node, + # FIXME: value type should be consistent, or we should serialise + {k: (v if isinstance(v, list) else [v]) for k,v in item.items()}, + "", + item["id"], + "" + ) + except BridgeException as e: + dialog.notification.show( + f"Can't udate list item: {e.message}", + level="error" + ) + else: + evt.target.checked = checked + class_update_method("list-item-closed") + + +if list_type == "grocery": + for elt in document.select('.click_to_delete'): + elt.bind("click", lambda evt: aio.run(on_delete(evt))) + + for elt in document.select('.click_to_next_state'): + elt.bind("click", lambda evt: aio.run(on_next_state(evt))) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import data_objects +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + +name = "lists" +access = C.PAGES_ACCESS_PUBLIC +template = "list/overview.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node"], service="jid") + data = self.get_r_data(request) + service, node = data["service"], data["node"] + if node is None: + self.http_redirect(request, self.get_page_by_name("lists_disco").url) + if node == "@": + node = data["node"] = "" + template_data = request.template_data + template_data["url_list_items"] = self.get_url(service.full(), node or "@") + template_data["url_list_new"] = self.get_page_by_name("list_new").get_url( + service.full(), node or "@") + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node = data["service"], data["node"] + submitted_node = await self.host.bridge_call( + "ps_schema_submitted_node_get", + node or self.host.ns_map["tickets"] + ) + profile = self.get_profile(request) or C.SERVICE_PROFILE + + self.check_cache(request, C.CACHE_PUBSUB, service=service, node=node, short="tickets") + + try: + lists_types = self.get_page_data(request, "lists_types") + if lists_types is None: + lists_types = {} + self.set_page_data(request, "lists_types", lists_types) + list_type = lists_types[(service, node)] + except KeyError: + ns_tickets_type = self.host.ns_map["tickets_type"] + schema_raw = await self.host.bridge_call( + "ps_schema_dict_get", + service.full(), + submitted_node, + profile + ) + schema = data_format.deserialise(schema_raw) + try: + list_type_field = next( + f for f in schema["fields"] if f["type"] == "hidden" + and f.get("name") == ns_tickets_type + ) + except StopIteration: + list_type = lists_types[(service, node)] = None + else: + if list_type_field.get("value") is None: + list_type = None + else: + list_type = list_type_field["value"].lower().strip() + lists_types[(service, node)] = list_type + + data["list_type"] = template_data["list_type"] = list_type + + extra = self.get_pubsub_extra(request) + extra["labels_as_list"] = C.BOOL_TRUE + self.handle_search(request, extra) + + list_raw = await self.host.bridge_call( + "list_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [], + "", + data_format.serialise(extra), + profile, + ) + if profile != C.SERVICE_PROFILE: + try: + affiliations = await self.host.bridge_call( + "ps_node_affiliations_get", + service.full() if service else "", + submitted_node, + profile + ) + except BridgeException as e: + log.warning( + f"Can't get affiliations for node {submitted_node!r} at {service}: {e}" + ) + template_data["owner"] = False + else: + is_owner = affiliations.get(self.get_jid(request).userhost()) == 'owner' + template_data["owner"] = is_owner + if is_owner: + self.expose_to_scripts( + request, + affiliations={str(e): str(a) for e, a in affiliations.items()} + ) + else: + template_data["owner"] = False + + list_items, metadata = data_format.deserialise(list_raw, type_check=list) + template_data["list_items"] = [ + template_xmlui.create(self.host, x) for x in list_items + ] + view_url = self.get_page_by_name('list_view').get_url(service.full(), node or '@') + template_data["on_list_item_click"] = data_objects.OnClick( + url=f"{view_url}/{{item.id}}" + ) + self.set_pagination(request, metadata) + self.expose_to_scripts( + request, + lists_ns=self.host.ns_map["tickets"], + pubsub_service=service.full(), + pubsub_node=node, + list_type=list_type, + ) + + +async def on_data_post(self, request): + data = self.get_r_data(request) + profile = self.get_profile(request) + service = data["service"] + node = data["node"] + list_type = self.get_posted_data(request, ("type",)) + if list_type == "grocery": + name, quantity = self.get_posted_data(request, ("name", "quantity")) + if not name: + self.page_error(request, C.HTTP_BAD_REQUEST) + item_data = { + "name": [name], + } + if quantity: + item_data["quantity"] = [quantity] + await self.host.bridge_call( + "list_set", service.full(), node, item_data, "", "", "", profile + ) + return C.POST_NO_CONFIRM + else: + raise NotImplementedError( + f"Can't use quick list item set for list of type {list_type!r}" + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/view_item/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,57 @@ +from browser import document, window, aio +from bridge import AsyncBridge as Bridge +import dialog + +try: + pubsub_service = window.pubsub_service + pubsub_node = window.pubsub_node + pubsub_item = window.pubsub_item +except AttributeError: + can_delete = False +else: + bridge = Bridge() + can_delete = True + + +async def on_delete(evt): + evt.stopPropagation() + confirmed = await dialog.Confirm( + "This item will be deleted, are you sure?", + ok_label="delete", + ok_color="danger", + ).ashow() + if confirmed: + try: + comments_service = window.comments_service + comments_node = window.comments_node + except AttributeError: + pass + else: + print(f"deleting comment node at [{comments_service}] {comments_node!r}") + try: + await bridge.ps_node_delete(comments_service, comments_node) + except Exception as e: + dialog.notification.show( + f"Can't delete comment node: {e}", + level="error" + ) + + print(f"deleting list item {pubsub_item!r} at [{pubsub_service}] {pubsub_node!r}") + try: + await bridge.ps_item_retract(pubsub_service, pubsub_node, pubsub_item, True) + except Exception as e: + dialog.notification.show( + f"Can't delete list item: {e}", + level="error" + ) + else: + # FIXME: Q&D way to get list view URL, need to have a proper method (would + # be nice to have a way to reference pages by name from browser) + list_url = '/'.join(window.location.pathname.split('/')[:-1]).replace( + 'view_item', 'view') + window.location.replace(list_url) + + +if can_delete: + for elt in document.select('.action_delete'): + elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/lists/view_item/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +from twisted.words.protocols.jabber import jid +from libervia.backend.core.i18n import _, D_ +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import uri +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.web.server.constants import Const as C +from libervia.web.server.utils import SubPage +from libervia.web.server import session_iface + +log = getLogger(__name__) + + +name = "list_view" +access = C.PAGES_ACCESS_PUBLIC +template = "list/item.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@") + data = self.get_r_data(request) + if data["item_id"] is None: + log.warning(_("no list item id specified")) + self.page_error(request, C.HTTP_BAD_REQUEST) + + +def add_breadcrumb(self, request, breadcrumbs): + data = self.get_r_data(request) + list_url = self.get_page_by_name("lists").get_url(data["service"].full(), + data.get("node") or "@") + breadcrumbs.append({ + "label": D_("List"), + "url": list_url + }) + breadcrumbs.append({ + "label": D_("Item"), + }) + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + session = self.host.get_session_data(request, session_iface.IWebSession) + service, node, item_id = ( + data.get("service", ""), + data.get("node", ""), + data["item_id"], + ) + namespace = node or self.host.ns_map["tickets"] + node = await self.host.bridge_call("ps_schema_submitted_node_get", namespace) + + profile = self.get_profile(request) + if profile is None: + profile = C.SERVICE_PROFILE + + list_raw = await self.host.bridge_call( + "list_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [item_id], + "", + data_format.serialise({"labels_as_list": C.BOOL_TRUE}), + profile, + ) + list_items, __ = data_format.deserialise(list_raw, type_check=list) + list_item = [template_xmlui.create(self.host, x) for x in list_items][0] + template_data["item"] = list_item + try: + comments_uri = list_item.widgets["comments_uri"].value + except KeyError: + pass + else: + if comments_uri: + uri_data = uri.parse_xmpp_uri(comments_uri) + template_data["comments_node"] = comments_node = uri_data["node"] + template_data["comments_service"] = comments_service = uri_data["path"] + try: + comments = data_format.deserialise(await self.host.bridge_call( + "mb_get", comments_service, comments_node, C.NO_LIMIT, [], + data_format.serialise({}), profile + )) + except BridgeException as e: + if e.classname == 'NotFound' or e.condition == 'item-not-found': + log.warning( + _("Can't find comment node at [{service}] {node!r}") + .format(service=comments_service, node=comments_node) + ) + else: + raise e + else: + template_data["comments"] = comments + template_data["login_url"] = self.get_page_redirect_url(request) + self.expose_to_scripts( + request, + comments_node=comments_node, + comments_service=comments_service, + ) + + if session.connected: + # we activate modification action (edit, delete) only if user is the publisher or + # the node owner + publisher = jid.JID(list_item.widgets["publisher"].value) + is_publisher = publisher.userhostJID() == session.jid.userhostJID() + affiliation = None + if not is_publisher: + affiliation = await self.host.get_affiliation(request, service, node) + if is_publisher or affiliation == "owner": + self.expose_to_scripts( + request, + pubsub_service = service.full(), + pubsub_node = node, + pubsub_item = item_id, + ) + template_data["can_modify"] = True + template_data["url_list_item_edit"] = self.get_url_by_path( + SubPage("list_edit"), + service.full(), + node or "@", + item_id, + ) + + # we add xmpp: URI + uri_args = {'path': service.full()} + uri_args['node'] = node + if item_id: + uri_args['item'] = item_id + template_data['xmpp_uri'] = uri.build_xmpp_uri('pubsub', **uri_args) + + +async def on_data_post(self, request): + type_ = self.get_posted_data(request, "type") + if type_ == "comment": + blog_page = self.get_page_by_name("blog_view") + await blog_page.on_data_post(self, request) + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/login/logged/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + + +from libervia.web.server import session_iface +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +"""SàT log-in page, with link to create an account""" + +template = "login/logged.html" + + +def prepare_render(self, request): + template_data = request.template_data + session_data = self.host.get_session_data(request, session_iface.IWebSession) + template_data["guest_session"] = session_data.guest + template_data["session_started"] = session_data.started
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/login/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.web.server.constants import Const as C +from libervia.web.server import session_iface +from twisted.internet import defer +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +"""SàT log-in page, with link to create an account""" + +name = "login" +access = C.PAGES_ACCESS_PUBLIC +template = "login/login.html" + + +def prepare_render(self, request): + template_data = request.template_data + + # we redirect to logged page if a session is active + profile = self.get_profile(request) + if profile is not None: + self.page_redirect("/login/logged", request) + + # login error message + session_data = self.host.get_session_data(request, session_iface.IWebSession) + login_error = session_data.pop_page_data(self, "login_error") + if login_error is not None: + template_data["S_C"] = C # we need server constants in template + template_data["login_error"] = login_error + template_data["empty_password_allowed"] = bool( + self.host.options["empty_password_allowed_warning_dangerous_list"] + ) + + # register page url + if self.host.options["allow_registration"]: + template_data["register_url"] = self.get_page_redirect_url(request, "register") + + # if login is set, we put it in template to prefill field + template_data["login"] = session_data.pop_page_data(self, "login") + + +def login_error(self, request, error_const): + """set login_error in page data + + @param error_const(unicode): one of login error constant + @return C.POST_NO_CONFIRM: avoid confirm message + """ + session_data = self.host.get_session_data(request, session_iface.IWebSession) + session_data.set_page_data(self, "login_error", error_const) + return C.POST_NO_CONFIRM + + +async def on_data_post(self, request): + profile = self.get_profile(request) + type_ = self.get_posted_data(request, "type") + if type_ == "disconnect": + if profile is None: + log.warning(_("Disconnect called when no profile is logged")) + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + self.host.purge_session(request) + return C.POST_NO_CONFIRM + elif type_ == "login": + login, password = self.get_posted_data(request, ("login", "password")) + try: + status = await self.host.connect(request, login, password) + except exceptions.ProfileUnknownError: + # the profile doesn't exist, we return the same error as for invalid password + # to avoid bruteforcing valid profiles + log.warning(f"login tentative with invalid profile: {login!r}") + return login_error(self, request, C.PROFILE_AUTH_ERROR) + except ValueError as e: + message = str(e) + if message in (C.XMPP_AUTH_ERROR, C.PROFILE_AUTH_ERROR): + return login_error(self, request, message) + else: + # this error was not expected! + raise e + except exceptions.TimeOutError: + return login_error(self, request, C.NO_REPLY) + else: + if status in (C.PROFILE_LOGGED, C.PROFILE_LOGGED_EXT_JID, C.SESSION_ACTIVE): + # Profile has been logged correctly + self.redirect_or_continue(request) + else: + log.error(_("Unhandled status: {status}".format(status=status))) + else: + self.page_error(request, C.HTTP_BAD_REQUEST)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/merge-requests/disco/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.words.protocols.jabber import jid +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + + +name = "merge-requests_disco" +access = C.PAGES_ACCESS_PUBLIC +template = "merge-request/discover.html" + + +def prepare_render(self, request): + mr_handlers_config = self.host.options["mr_handlers_json"] + if mr_handlers_config: + handlers = request.template_data["mr_handlers"] = [] + try: + for handler_data in mr_handlers_config: + service = handler_data["service"] + node = handler_data["node"] + name = handler_data["name"] + url = self.get_page_by_name("merge-requests").get_url(service, node) + handlers.append({"name": name, "url": url}) + except KeyError as e: + log.warning("Missing field in mr_handlers_json: {msg}".format(msg=e)) + except Exception as e: + log.warning("Can't decode mr handlers: {msg}".format(msg=e)) + + +def on_data_post(self, request): + jid_str = self.get_posted_data(request, "jid") + try: + jid_ = jid.JID(jid_str) + except RuntimeError: + self.page_error(request, C.HTTP_BAD_REQUEST) + # for now we just use default node + url = self.get_page_by_name("merge-requests").get_url(jid_.full(), "@") + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/merge-requests/edit/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger + +"""merge-requests edition""" + +name = "merge-requests_edit" +access = C.PAGES_ACCESS_PROFILE +template = "merge-request/edit.html" +log = getLogger(__name__) + + +def parse_url(self, request): + try: + item_id = self.next_path(request) + except IndexError: + log.warning(_("no list item id specified")) + self.page_error(request, C.HTTP_BAD_REQUEST) + + data = self.get_r_data(request) + data["list_item_id"] = item_id + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node, list_item_id = ( + data.get("service", ""), + data.get("node", ""), + data["list_item_id"], + ) + profile = self.get_profile(request) + + ignore = ( + "publisher", + "author", + "author_jid", + "author_email", + "created", + "updated", + "comments_uri", + "request_data", + "type", + ) + merge_requests = data_format.deserialise( + await self.host.bridge_call( + "merge_requests_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [list_item_id], + "", + data_format.serialise({}), + profile, + ) + ) + list_item = template_xmlui.create( + self.host, merge_requests['items'][0], ignore=ignore + ) + + try: + # small trick to get a one line text input instead of the big textarea + list_item.widgets["labels"].type = "string" + list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace( + "\n", ", " + ) + except KeyError: + pass + + # same as list_edit + wid = list_item.widgets['body'] + if wid.type == "xhtmlbox": + wid.type = "textbox" + wid.value = await self.host.bridge_call( + "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown", + False, profile) + + template_data["new_list_item_xmlui"] = list_item + + +async def on_data_post(self, request): + data = self.get_r_data(request) + service = data["service"] + node = data["node"] + list_item_id = data["list_item_id"] + posted_data = self.get_all_posted_data(request) + if not posted_data["title"] or not posted_data["body"]: + self.page_error(request, C.HTTP_BAD_REQUEST) + try: + posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")] + except (KeyError, IndexError): + pass + profile = self.get_profile(request) + + # we convert back body to XHTML + body = await self.host.bridge_call( + "syntax_convert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML, + False, profile) + posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML, + body=body)] + + extra = {'update': True} + await self.host.bridge_call( + "merge_request_set", + service.full(), + node, + "", + "auto", + posted_data, + "", + list_item_id, + data_format.serialise(extra), + profile, + ) + # we don't want to redirect to edit page on success, but to list overview + data["post_redirect_page"] = ( + self.get_page_by_name("merge-requests"), + service.full(), + node or "@", + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/merge-requests/new/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + + +name = "merge-requests_new" +access = C.PAGES_ACCESS_PUBLIC +template = "merge-request/create.html"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/merge-requests/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import data_objects +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + + +name = "merge-requests" +access = C.PAGES_ACCESS_PUBLIC +template = "list/overview.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "node"], service="jid") + data = self.get_r_data(request) + service, node = data["service"], data["node"] + if node is None: + self.page_redirect("merge-requests_disco", request) + if node == "@": + node = data["node"] = "" + self.check_cache( + request, C.CACHE_PUBSUB, service=service, node=node, short="merge-requests" + ) + template_data = request.template_data + template_data["url_list_items"] = self.get_page_by_name("merge-requests").get_url( + service.full(), node + ) + template_data["url_list_new"] = self.get_sub_page_url(request, "merge-requests_new") + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + service, node = data["service"], data["node"] + profile = self.get_profile(request) or C.SERVICE_PROFILE + + merge_requests = data_format.deserialise( + await self.host.bridge_call( + "merge_requests_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [], + "", + data_format.serialise({"labels_as_list": C.BOOL_TRUE}), + profile, + ) + ) + + template_data["list_items"] = [ + template_xmlui.create(self.host, x) for x in merge_requests['items'] + ] + template_data["on_list_item_click"] = data_objects.OnClick( + url=self.get_sub_page_url(request, "merge-requests_view") + "/{item.id}" + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/merge-requests/view/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.web.server.utils import SubPage +from libervia.web.server import session_iface +from twisted.words.protocols.jabber import jid +from libervia.backend.tools.common import template_xmlui +from libervia.backend.tools.common import uri +from libervia.backend.tools.common import data_format +from libervia.backend.core.log import getLogger + +name = "merge-requests_view" +access = C.PAGES_ACCESS_PUBLIC +template = "merge-request/item.html" +log = getLogger(__name__) + + +def parse_url(self, request): + try: + item_id = self.next_path(request) + except IndexError: + log.warning(_("no list item id specified")) + self.page_error(request, C.HTTP_BAD_REQUEST) + + data = self.get_r_data(request) + data["list_item_id"] = item_id + + +async def prepare_render(self, request): + data = self.get_r_data(request) + template_data = request.template_data + session = self.host.get_session_data(request, session_iface.IWebSession) + service, node, list_item_id = ( + data.get("service", ""), + data.get("node", ""), + data["list_item_id"], + ) + profile = self.get_profile(request) + + if profile is None: + profile = C.SERVICE_PROFILE + + merge_requests = data_format.deserialise( + await self.host.bridge_call( + "merge_requests_get", + service.full() if service else "", + node, + C.NO_LIMIT, + [list_item_id], + "", + data_format.serialise({"parse": C.BOOL_TRUE, "labels_as_list": C.BOOL_TRUE}), + profile, + ) + ) + list_item = template_xmlui.create( + self.host, merge_requests['items'][0], ignore=["request_data", "type"] + ) + template_data["item"] = list_item + template_data["patches"] = merge_requests['items_patches'][0] + comments_uri = list_item.widgets["comments_uri"].value + if comments_uri: + uri_data = uri.parse_xmpp_uri(comments_uri) + template_data["comments_node"] = comments_node = uri_data["node"] + template_data["comments_service"] = comments_service = uri_data["path"] + template_data["comments"] = data_format.deserialise(await self.host.bridge_call( + "mb_get", comments_service, comments_node, C.NO_LIMIT, [], + data_format.serialise({}), profile + )) + + template_data["login_url"] = self.get_page_redirect_url(request) + + if session.connected: + # we set edition URL only if user is the publisher or the node owner + publisher = jid.JID(list_item.widgets["publisher"].value) + is_publisher = publisher.userhostJID() == session.jid.userhostJID() + affiliation = None + if not is_publisher: + node = node or self.host.ns_map["merge_requests"] + affiliation = await self.host.get_affiliation(request, service, node) + if is_publisher or affiliation == "owner": + template_data["url_list_item_edit"] = self.get_url_by_path( + SubPage("merge-requests"), + service.full(), + node or "@", + SubPage("merge-requests_edit"), + list_item_id, + ) + + +async def on_data_post(self, request): + type_ = self.get_posted_data(request, "type") + if type_ == "comment": + blog_page = self.get_page_by_name("blog_view") + await blog_page.on_data_post(self, request) + else: + log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/photos/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,62 @@ +from browser import window, bind, DOMNode +from javascript import JSON +from bridge import Bridge +import dialog + +bridge = Bridge() + + +def album_delete_cb(item_elt, item): + print(f"deleted {item['name']}") + + +def album_delete_eb(failure, item_elt, item): + # TODO: cleaner error notification + window.alert(f"error while deleting {item['name']}: failure") + + +def interest_retract_cb(item_elt, item): + print(f"{item['name']} removed successfuly from list of interests") + item_elt.classList.add("state_deleted") + item_elt.bind("transitionend", lambda evt: item_elt.remove()) + bridge.file_sharing_delete( + item['service'], + item.get('path', ''), + item.get('files_namespace', ''), + callback=lambda __: album_delete_cb(item_elt, item), + errback=lambda failure: album_delete_eb(failure, item_elt, item), + ) + + +def interest_retract_eb(failure_, item_elt, item): + # TODO: cleaner error notification + window.alert(f"Can't delete album {item['name']}: {failure_['message']}") + + +def delete_ok(evt, notif_elt, item_elt, item): + bridge.interest_retract( + "", item['id'], + callback=lambda: interest_retract_cb(item_elt, item), + errback=lambda failure:interest_retract_eb(failure, item_elt, item)) + + +def delete_cancel(evt, notif_elt, item_elt, item): + notif_elt.remove() + item_elt.classList.remove("selected_for_deletion") + + +@bind(".action_delete", "click") +def on_delete(evt): + evt.stopPropagation() + target = evt.currentTarget + item_elt = DOMNode(target.closest('.item')) + item_elt.classList.add("selected_for_deletion") + item = JSON.parse(item_elt.dataset.item) + dialog.Confirm( + f"album {item['name']!r} will be deleted (inluding all its photos), " + f"are you sure?", + ok_label="delete", + ).show( + ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item), + cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item), + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/photos/album/_browser/__init__.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,305 @@ +from browser import document, window, bind, html, DOMNode, aio +from javascript import JSON +from bridge import Bridge, AsyncBridge +from template import Template +import dialog +from slideshow import SlideShow +from invitation import InvitationManager +import alt_media_player +# we use tmp_aio because `blob` is not handled in Brython's aio +import tmp_aio +import loading + + +cache_path = window.cache_path +files_service = window.files_service +files_path = window.files_path +try: + affiliations = window.affiliations.to_dict() +except AttributeError: + pass +bridge = Bridge() +async_bridge = AsyncBridge() + +alt_media_player.install_if_needed() + +photo_tpl = Template('photo/item.html') +player_tpl = Template('components/media_player.html') + +# file upload + +def on_progress(ev, photo_elt): + if ev.lengthComputable: + percent = int(ev.loaded/ev.total*100) + update_progress(photo_elt, percent) + + +def on_load(file_, photo_elt): + update_progress(photo_elt, 100) + photo_elt.classList.add("progress_finished") + photo_elt.classList.remove("progress_started") + photo_elt.select_one('.action_delete').bind("click", on_delete) + print(f"file {file_.name} uploaded correctly") + + +def on_error(failure, file_, photo_elt): + dialog.notification.show( + f"can't upload {file_.name}: {failure}", + level="error" + ) + + +def update_progress(photo_elt, new_value): + progress_elt = photo_elt.select_one("progress") + progress_elt.value = new_value + progress_elt.text = f"{new_value}%" + + +def on_slot_cb(file_, upload_slot, photo_elt): + put_url, get_url, headers = upload_slot + xhr = window.XMLHttpRequest.new() + xhr.open("PUT", put_url, True) + xhr.upload.bind('progress', lambda ev: on_progress(ev, photo_elt)) + xhr.upload.bind('load', lambda ev: on_load(file_, photo_elt)) + xhr.upload.bind('error', lambda ev: on_error(xhr.response, file_, photo_elt)) + xhr.setRequestHeader('Xmpp-File-Path', window.encodeURIComponent(files_path)) + xhr.setRequestHeader('Xmpp-File-No-Http', "true") + xhr.send(file_) + + +def on_slot_eb(file_, failure, photo_elt): + dialog.notification.show( + f"Can't get upload slot: {failure['message']}", + level="error" + ) + photo_elt.remove() + + +def upload_files(files): + print(f"uploading {len(files)} files") + album_items = document['album_items'] + for file_ in files: + url = window.URL.createObjectURL(file_) + photo_elt = photo_tpl.get_elt({ + "file": { + "name": file_.name, + # we don't want to open the file on click, it's not yet the + # uploaded URL + "url": url, + # we have no thumb yet, so we use the whole image + # TODO: reduce image for preview + "thumb_url": url, + }, + "uploading": True, + }) + photo_elt.classList.add("progress_started") + album_items <= photo_elt + + bridge.file_http_upload_get_slot( + file_.name, + file_.size, + file_.type or '', + files_service, + callback=lambda upload_slot, file_=file_, photo_elt=photo_elt: + on_slot_cb(file_, upload_slot, photo_elt), + errback=lambda failure, file_=file_, photo_elt=photo_elt: + on_slot_eb(file_, failure, photo_elt), + ) + + +@bind("#file_drop", "drop") +def on_file_select(evt): + evt.stopPropagation() + evt.preventDefault() + files = evt.dataTransfer.files + upload_files(files) + + +@bind("#file_drop", "dragover") +def on_drag_over(evt): + evt.stopPropagation() + evt.preventDefault() + evt.dataTransfer.dropEffect = 'copy' + + +@bind("#file_input", "change") +def on_file_input_change(evt): + files = evt.currentTarget.files + upload_files(files) + +# delete + +def file_delete_cb(item_elt, item): + item_elt.classList.add("state_deleted") + item_elt.bind("transitionend", lambda evt: item_elt.remove()) + print(f"deleted {item['name']}") + + +def file_delete_eb(failure, item_elt, item): + dialog.notification.show( + f"error while deleting {item['name']}: failure", + level="error" + ) + + +def delete_ok(evt, notif_elt, item_elt, item): + file_path = f"{files_path.rstrip('/')}/{item['name']}" + bridge.file_sharing_delete( + files_service, + file_path, + "", + callback=lambda : file_delete_cb(item_elt, item), + errback=lambda failure: file_delete_eb(failure, item_elt, item), + ) + + +def delete_cancel(evt, notif_elt, item_elt, item): + notif_elt.remove() + item_elt.classList.remove("selected_for_deletion") + + +def on_delete(evt): + evt.stopPropagation() + target = evt.currentTarget + item_elt = DOMNode(target.closest('.item')) + item_elt.classList.add("selected_for_deletion") + item = JSON.parse(item_elt.dataset.item) + dialog.Confirm( + f"{item['name']!r} will be deleted, are you sure?", + ok_label="delete", + ok_color="danger", + ).show( + ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item), + cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item), + ) + +# cover + +async def cover_ok(evt, notif_elt, item_elt, item): + # we first need to get a blob of the image + img_elt = item_elt.select_one("img") + # the simplest way is to download it + r = await tmp_aio.ajax("GET", img_elt.src, "blob") + if r.status != 200: + dialog.notification.show( + f"can't retrieve cover: {r.status}: {r.statusText}", + level="error" + ) + return + img_blob = r.response + # now we'll upload it via HTTP Upload, we need a slow + img_name = img_elt.src.rsplit('/', 1)[-1] + img_size = img_blob.size + + slot = await async_bridge.file_http_upload_get_slot( + img_name, + img_size, + '', + files_service + ) + get_url, put_url, headers = slot + # we have the slot, we can upload image + r = await tmp_aio.ajax("PUT", put_url, "", img_blob) + if r.status != 201: + dialog.notification.show( + f"can't upload cover: {r.status}: {r.statusText}", + level="error" + ) + return + extra = {"thumb_url": get_url} + album_name = files_path.rsplit('/', 1)[-1] + await async_bridge.interests_file_sharing_register( + files_service, + "photos", + "", + files_path, + album_name, + JSON.stringify(extra), + ) + dialog.notification.show("Album cover has been changed") + + +def cover_cancel(evt, notif_elt, item_elt, item): + notif_elt.remove() + item_elt.classList.remove("selected_for_action") + + +def on_cover(evt): + evt.stopPropagation() + target = evt.currentTarget + item_elt = DOMNode(target.closest('.item')) + item_elt.classList.add("selected_for_action") + item = JSON.parse(item_elt.dataset.item) + dialog.Confirm( + f"use {item['name']!r} for this album cover?", + ok_label="use as cover", + ).show( + ok_cb=lambda evt, notif_elt: aio.run(cover_ok(evt, notif_elt, item_elt, item)), + cancel_cb=lambda evt, notif_elt: cover_cancel(evt, notif_elt, item_elt, item), + ) + + +# slideshow + +@bind(".photo_thumb_click", "click") +def photo_click(evt): + evt.stopPropagation() + evt.preventDefault() + slideshow = SlideShow() + target = evt.currentTarget + clicked_item_elt = DOMNode(target.closest('.item')) + + slideshow.attach() + for idx, item_elt in enumerate(document.select('.item')): + item = JSON.parse(item_elt.dataset.item) + try: + biggest_thumb = item['extra']['thumbnails'][-1] + thumb_url = f"{cache_path}{biggest_thumb['filename']}" + except (KeyError, IndexError) as e: + print(f"Can't get full screen thumbnail URL: {e}") + thumb_url = None + if item.get("mime_type", "")[:5] == "video": + player = alt_media_player.MediaPlayer( + [item['url']], + poster = thumb_url, + reduce_click_area = True + ) + elt = player.elt + elt.classList.add("slide_video", "no_fullscreen") + slideshow.add_slide( + elt, + item, + options={ + "flags": (alt_media_player.NO_PAGINATION, alt_media_player.NO_SCROLLBAR), + "exit_callback": player.reset, + } + ) + else: + slideshow.add_slide(html.IMG(src=thumb_url or item['url'], Class="slide_img"), item) + if item_elt == clicked_item_elt: + slideshow.index = idx + + +for elt in document.select('.action_delete'): + elt.bind("click", on_delete) +for elt in document.select('.action_cover'): + elt.bind("click", on_cover) + +# manage + + +@bind("#button_manage", "click") +def manage_click(evt): + evt.stopPropagation() + evt.preventDefault() + manager = InvitationManager("photos", {"service": files_service, "path": files_path}) + manager.attach(affiliations=affiliations) + + +# hint +@bind("#hint .click_to_delete", "click") +def remove_hint(evt): + document['hint'].remove() + + +loading.remove_loading_screen()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/photos/album/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + + +from libervia.backend.core.i18n import D_ +from libervia.backend.core.log import getLogger +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + +name = "photos_album" +label = D_("Photos Album") +access = C.PAGES_ACCESS_PROFILE +template = "photo/album.html" + + +def parse_url(self, request): + self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="") + + +def prepare_render(self, request): + data = self.get_r_data(request) + data["thumb_limit"] = 800 + data["retrieve_comments"] = True + files_page = self.get_page_by_name("files_list") + return files_page.prepare_render(self, request) + + +def on_data_post(self, request): + blog_page = self.get_page_by_name("blog_view") + return blog_page.on_data_post(self, request)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/photos/new/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +from libervia.web.server.constants import Const as C +from twisted.internet import defer +from libervia.backend.core.log import getLogger +from libervia.backend.core.i18n import D_ +from libervia.backend.core import exceptions +from libervia.frontends.bridge.bridge_frontend import BridgeException + +"""creation of new events""" + +name = "photos_new" +access = C.PAGES_ACCESS_PROFILE +template = "photo/create.html" +log = getLogger(__name__) + + +async def on_data_post(self, request): + request_data = self.get_r_data(request) + profile = self.get_profile(request) + name = self.get_posted_data(request, "name").replace('/', '_') + albums_path = "/albums" + album_path = f"{albums_path}/{name}" + if profile is None: + self.page_error(request, C.HTTP_BAD_REQUEST) + fis_ns = self.host.ns_map["fis"] + http_upload_ns = self.host.ns_map["http_upload"] + entities_services, __, __ = await self.host.bridge_call( + "disco_find_by_features", + [fis_ns, http_upload_ns], + [], + False, + True, + False, + False, + False, + profile + ) + try: + fis_service = next(iter(entities_services)) + except StopIteration: + raise exceptions.DataError(D_( + "You server has no service to create a photo album, please ask your server " + "administrator to add one")) + + try: + await self.host.bridge_call( + "fis_create_dir", + fis_service, + "", + albums_path, + {"access_model": "open"}, + profile + ) + except BridgeException as e: + if e.condition == 'conflict': + pass + else: + log.error(f"Can't create {albums_path} path: {e}") + raise e + + try: + await self.host.bridge_call( + "fis_create_dir", + fis_service, + "", + album_path, + {"access_model": "whitelist"}, + profile + ) + except BridgeException as e: + if e.condition == 'conflict': + pass + else: + log.error(f"Can't create {album_path} path: {e}") + raise e + + await self.host.bridge_call( + "interests_file_sharing_register", + fis_service, + "photos", + "", + album_path, + name, + "", + profile + ) + log.info(f"album {name} created") + request_data["post_redirect_page"] = self.get_page_by_name("photos") + defer.returnValue(C.POST_NO_CONFIRM)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/photos/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.internet import defer +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +name = "photos" +access = C.PAGES_ACCESS_PROFILE +template = "photo/discover.html" + + +@defer.inlineCallbacks +def prepare_render(self, request): + profile = self.get_profile(request) + template_data = request.template_data + namespace = self.host.ns_map["fis"] + if profile is not None: + try: + interests = yield self.host.bridge_call( + "interests_list", "", "", namespace, profile) + except Exception: + log.warning(_("Can't get interests list for {profile}").format( + profile=profile)) + else: + # we only want photo albums + filtered_interests = [] + for interest in interests: + if interest.get('subtype') != 'photos': + continue + path = interest.get('path', '') + path_args = [p for p in path.split('/') if p] + interest["url"] = self.get_sub_page_url( + request, + "photos_album", + interest['service'], + *path_args + ) + filtered_interests.append(interest) + + template_data['interests'] = filtered_interests + + template_data["url_photos_new"] = self.get_sub_page_url(request, "photos_new") + + +@defer.inlineCallbacks +def on_data_post(self, request): + jid_ = self.get_posted_data(request, "jid") + url = self.get_page_by_name("photos_album").get_url(jid_) + self.http_redirect(request, url)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/register/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from libervia.web.server import session_iface +from twisted.internet import defer +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +"""SàT account registration page""" + +name = "register" +access = C.PAGES_ACCESS_PUBLIC +template = "login/register.html" + + +def prepare_render(self, request): + if not self.host.options["allow_registration"]: + self.page_error(request, C.HTTP_FORBIDDEN) + profile = self.get_profile(request) + if profile is not None: + self.page_redirect("/login/logged", request) + template_data = request.template_data + template_data["login_url"] = self.get_page_by_name("login").url + template_data["S_C"] = C # we need server constants in template + + # login error message + session_data = self.host.get_session_data(request, session_iface.IWebSession) + login_error = session_data.pop_page_data(self, "login_error") + if login_error is not None: + template_data["login_error"] = login_error + + # if fields were already filled, we reuse them + for k in ("login", "email", "password"): + template_data[k] = session_data.pop_page_data(self, k) + + +@defer.inlineCallbacks +def on_data_post(self, request): + type_ = self.get_posted_data(request, "type") + if type_ == "register": + login, email, password = self.get_posted_data( + request, ("login", "email", "password") + ) + status = yield self.host.register_new_account(request, login, password, email) + session_data = self.host.get_session_data(request, session_iface.IWebSession) + if status == C.REGISTRATION_SUCCEED: + # we prefill login field for login page + session_data.set_page_data(self.get_page_by_name("login"), "login", login) + # if we have a redirect_url we follow it + self.redirect_or_continue(request) + # else we redirect to login page + self.http_redirect(request, self.get_page_by_name("login").url) + else: + session_data.set_page_data(self, "login_error", status) + l = locals() + for k in ("login", "email", "password"): + # we save fields so user doesn't have to enter them again + session_data.set_page_data(self, k, l[k]) + defer.returnValue(C.POST_NO_CONFIRM) + else: + self.page_error(request, C.HTTP_BAD_REQUEST)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/u/atom.xml/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + +redirect = "blog_feed_atom" +name = "user_blog_feed_atom"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/u/blog/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + + +name = "user_blog" + + +def parse_url(self, request): + # in this subpage, we want path args and query args + # (i.e. what's remaining in URL: filters, id, etc.) + # to be used by blog's url parser, so we don't skip parse_url + data = self.get_r_data(request) + service = data["service"] + self.page_redirect( + "blog_view", request, skip_parse_url=False, path_args=[service.full(), "@"] + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/u/page_meta.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + + +from libervia.web.server.constants import Const as C +from twisted.internet import defer +from twisted.words.protocols.jabber import jid + +"""page used to target a user profile, e.g. for public blog""" + +name = "user" +access = C.PAGES_ACCESS_PUBLIC # can be a callable +template = "blog/articles.html" +url_cache = True + + +@defer.inlineCallbacks +def parse_url(self, request): + try: + prof_requested = self.next_path(request) + except IndexError: + self.page_error(request) + + data = self.get_r_data(request) + + target_profile = yield self.host.bridge_call("profile_name_get", prof_requested) + request.template_data["target_profile"] = target_profile + target_jid = yield self.host.bridge_call( + "param_get_a_async", "JabberID", "Connection", "value", profile_key=target_profile + ) + target_jid = jid.JID(target_jid) + data["service"] = target_jid + + # if URL is parsed here, we'll have atom.xml available and we need to + # add the link to the page + atom_url = self.get_sub_page_url(request, 'user_blog_feed_atom') + request.template_data['atom_url'] = atom_url + request.template_data.setdefault('links', []).append({ + "href": atom_url, + "type": "application/atom+xml", + "rel": "alternate", + "title": "{target_profile}'s blog".format(target_profile=target_profile)}) + +def add_breadcrumb(self, request, breadcrumbs): + # we don't want a breadcrumb here + pass + + +@defer.inlineCallbacks +def prepare_render(self, request): + data = self.get_r_data(request) + self.check_cache( + request, C.CACHE_PUBSUB, service=data["service"], node=None, short="microblog" + ) + self.page_redirect("blog_view", request) + +def on_data_post(self, request): + return self.get_page_by_name("blog_view").on_data_post(self, request)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/classes.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Useful genertic classes used in Libervia""" + + +from collections import namedtuple + +WebsocketMeta = namedtuple("WebsocketMeta", ("url", "token", "debug")) +Notification = namedtuple("Notification", ("message", "level")) +Script = namedtuple("Script", ("src", "type", "content"), defaults=(None, None, ""))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/constants.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +# Libervia: a SàT frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ..common import constants + + +class Const(constants.Const): + + APP_NAME = "Libervia Web" + APP_COMPONENT = "web" + APP_NAME_ALT = APP_NAME + APP_NAME_FILE = "libervia_web" + CONFIG_SECTION = APP_COMPONENT.lower() + # the Libervia profile that is used for public operations (when nobody is connected) + SERVICE_PROFILE = "libervia" + + SESSION_TIMEOUT = 7200 # Session's timeout, after that the user will be disconnected + HTML_DIR = "html/" + THEMES_DIR = "themes/" + THEMES_URL = "themes" + MEDIA_DIR = "media/" + CARDS_DIR = "games/cards/tarot" + PAGES_DIR = "pages" + TASKS_DIR = "tasks" + LIBERVIA_CACHE = "libervia" + SITE_NAME_DEFAULT = "default" + # generated files will be accessible there + BUILD_DIR = "__b" + BUILD_DIR_DYN = "dyn" + # directory where build files are served to the client + PRODUCTION_BUILD_DIR = "sites" + # directory used for files needed temporarily (e.g. for compiling other files) + DEV_BUILD_DIR = "dev_build" + + TPL_RESOURCE = '_t' + + ERRNUM_BRIDGE_ERRBACK = 0 # FIXME + ERRNUM_LIBERVIA = 0 # FIXME + + # Security limit for Libervia (get/set params) + SECURITY_LIMIT = 5 + + # Security limit for Libervia server_side + SERVER_SECURITY_LIMIT = constants.Const.NO_SECURITY_LIMIT + + # keys for cache values we can get from browser + ALLOWED_ENTITY_DATA = {"avatar", "nick"} + + STATIC_RSM_MAX_LIMIT = 100 + STATIC_RSM_MAX_DEFAULT = 10 + STATIC_RSM_MAX_COMMENTS_DEFAULT = 10 + + ## Libervia pages ## + PAGES_META_FILE = "page_meta.py" + PAGES_BROWSER_DIR = "_browser" + PAGES_BROWSER_META_FILE = "browser_meta.json" + PAGES_ACCESS_NONE = ( + "none" + ) # no access to this page (using its path will return a 404 error) + PAGES_ACCESS_PUBLIC = "public" + PAGES_ACCESS_PROFILE = ( + "profile" + ) # a session with an existing profile must be started + PAGES_ACCESS_ADMIN = "admin" # only profiles set in admins_list can access the page + PAGES_ACCESS_ALL = ( + PAGES_ACCESS_NONE, + PAGES_ACCESS_PUBLIC, + PAGES_ACCESS_PROFILE, + PAGES_ACCESS_ADMIN, + ) + # names of the page to use for menu + DEFAULT_MENU = [ + "login", + "chat", + "blog", + "forums", + "photos", + "files", + "calendar", + "events", + "lists", + "merge-requests", + "calls" + # XXX: app is not available anymore since removal of pyjamas code with Python 3 + # port. It should come back at a later point with an alternative (Brython + # probably). + ] + + ## Session flags ## + FLAG_CONFIRM = "CONFIRM" + + ## Data post ## + POST_NO_CONFIRM = "POST_NO_CONFIRM" + + ## HTTP methods ## + HTTP_METHOD_GET = b"GET" + HTTP_METHOD_POST = b"POST" + + ## HTTP codes ## + HTTP_SEE_OTHER = 303 + HTTP_NOT_MODIFIED = 304 + HTTP_BAD_REQUEST = 400 + HTTP_UNAUTHORIZED = 401 + HTTP_FORBIDDEN = 403 + HTTP_NOT_FOUND = 404 + HTTP_INTERNAL_ERROR = 500 + HTTP_PROXY_ERROR = 502 + HTTP_SERVICE_UNAVAILABLE = 503 + + ## HTTP HEADERS ## + H_FORWARDED = "Forwarded" + H_X_FORWARDED_FOR = "X-Forwarded-For" + H_X_FORWARDED_HOST = "X-Forwarded-Host" + H_X_FORWARDED_PROTO = "X-Forwarded-Proto" + + + ## Cache ## + CACHE_PUBSUB = 0 + + ## Date/Time ## + HTTP_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + HTTP_MONTH = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", + "Nov", "Dec")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/html_tools.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +def sanitize_html(text): + """Sanitize HTML by escaping everything""" + # this code comes from official python wiki: http://wiki.python.org/moin/EscapingHtml + html_escape_table = { + "&": "&", + '"': """, + "'": "'", + ">": ">", + "<": "<", + } + + return "".join(html_escape_table.get(c, c) for c in text) + + +def convert_new_lines_to_xhtml(text): + return text.replace("\n", "<br/>")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/launcher.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Script launching Libervia server""" + +from libervia.backend.core import launcher +from libervia.web.server.constants import Const as C + + +class Launcher(launcher.Launcher): + APP_NAME=C.APP_NAME + APP_NAME_FILE=C.APP_NAME_FILE + + +if __name__ == '__main__': + Launcher.run()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/pages.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,1860 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import annotations +import copy +from functools import reduce +import hashlib +import json +import os.path +from pathlib import Path +import time +import traceback +from typing import List, Optional, Union +import urllib.error +import urllib.parse +import urllib.request +import uuid + +from twisted.internet import defer +from twisted.python import failure +from twisted.python.filepath import FilePath +from twisted.web import server +from twisted.web import resource as web_resource +from twisted.web import util as web_util +from twisted.words.protocols.jabber import jid + +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import date_utils +from libervia.backend.tools.common import utils +from libervia.backend.tools.common import data_format +from libervia.backend.tools.utils import as_deferred +from libervia.frontends.bridge.bridge_frontend import BridgeException + +from . import session_iface +from .classes import WebsocketMeta +from .classes import Script +from .constants import Const as C +from .resources import LiberviaRootResource +from .utils import SubPage, quote + +log = getLogger(__name__) + + +class CacheBase(object): + def __init__(self): + self._created = time.time() + self._last_access = self._created + + @property + def created(self): + return self._created + + @property + def last_access(self): + return self._last_access + + @last_access.setter + def last_access(self, timestamp): + self._last_access = timestamp + + +class CachePage(CacheBase): + def __init__(self, rendered): + super(CachePage, self).__init__() + self._created = time.time() + self._last_access = self._created + self._rendered = rendered + self._hash = hashlib.sha256(rendered).hexdigest() + + @property + def rendered(self): + return self._rendered + + @property + def hash(self): + return self._hash + + +class CacheURL(CacheBase): + def __init__(self, request): + super(CacheURL, self).__init__() + try: + self._data = copy.deepcopy(request.data) + except AttributeError: + self._data = {} + self._template_data = copy.deepcopy(request.template_data) + self._prepath = request.prepath[:] + self._postpath = request.postpath[:] + del self._template_data["csrf_token"] + + def use(self, request): + self.last_access = time.time() + request.data = copy.deepcopy(self._data) + request.template_data.update(copy.deepcopy(self._template_data)) + request.prepath = self._prepath[:] + request.postpath = self._postpath[:] + + +class LiberviaPage(web_resource.Resource): + isLeaf = True # we handle subpages ourself + cache = {} + # Set of tuples (service/node/sub_id) of nodes subscribed for caching + # sub_id can be empty string if not handled by service + cache_pubsub_sub = set() + + def __init__( + self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None, + access=None, dynamic=True, parse_url=None, add_breadcrumb=None, + prepare_render=None, render=None, template=None, on_data_post=None, on_data=None, + url_cache=False, replace_on_conflict=False + ): + """Initiate LiberviaPage instance + + LiberviaPages are the main resources of Libervia, using easy to set python files + The non mandatory arguments are the variables found in page_meta.py + @param host(Libervia): the running instance of Libervia + @param vhost_root(web_resource.Resource): root resource of the virtual host which + handle this page. + @param root_dir(Path): absolute file path of the page + @param url(unicode): relative URL to the page + this URL may not be valid, as pages may require path arguments + @param name(unicode, None): if not None, a unique name to identify the page + can then be used for e.g. redirection + "/" is not allowed in names (as it can be used to construct URL paths) + @param redirect(unicode, None): if not None, this page will be redirected. + A redirected parameter is used as in self.page_redirect. + parse_url will not be skipped + using this redirect parameter is called "full redirection" + using self.page_redirect is called "partial redirection" (because some + rendering method can still be used, e.g. parse_url) + @param access(unicode, None): permission needed to access the page + None means public access. + Pages inherit from parent pages: e.g. if a "settings" page is restricted + to admins, and if "settings/blog" is public, it still can only be accessed by + admins. See C.PAGES_ACCESS_* for details + @param dynamic(bool): if True, activate websocket for bidirectional communication + @param parse_url(callable, None): if set it will be called to handle the URL path + after this method, the page will be rendered if noting is left in path + (request.postpath) else a the request will be transmitted to a subpage + @param add_breadcrumb(callable, None): if set, manage the breadcrumb data for this + page, otherwise it will be set automatically from page name or label. + @param prepare_render(callable, None): if set, will be used to prepare the + rendering. That often means gathering data using the bridge + @param render(callable, None): if template is not set, this method will be + called and what it returns will be rendered. + This method is mutually exclusive with template and must return a unicode + string. + @param template(unicode, None): path to the template to render. + This method is mutually exclusive with render + @param on_data_post(callable, None): method to call when data is posted + None if data post is not handled + "continue" if data post is not handled there, and we must not interrupt + workflow (i.e. it's handled in "render" method). + otherwise, on_data_post can return a string with following value: + - C.POST_NO_CONFIRM: confirm flag will not be set + on_data_post can raise following exceptions: + - exceptions.DataError: value is incorrect, message will be displayed + as a notification + @param on_data(callable, None): method to call when dynamic data is sent + this method is used with Libervia's websocket mechanism + @param url_cache(boolean): if set, result of parse_url is cached (per profile). + Useful when costly calls (e.g. network) are done while parsing URL. + @param replace_on_conflict(boolean): if True, don't raise ConflictError if a + page of this name already exists, but replace it + """ + + web_resource.Resource.__init__(self) + self.host = host + self.vhost_root = vhost_root + self.root_dir = root_dir + self.url = url + self.name = name + self.label = label + self.dyn_data = {} + if name is not None: + if (name in self.named_pages + and not (replace_on_conflict and self.named_pages[name].url == url)): + raise exceptions.ConflictError( + _('a Libervia page named "{}" already exists'.format(name))) + if "/" in name: + raise ValueError(_('"/" is not allowed in page names')) + if not name: + raise ValueError(_("a page name can't be empty")) + self.named_pages[name] = self + if access is None: + access = C.PAGES_ACCESS_PUBLIC + if access not in ( + C.PAGES_ACCESS_PUBLIC, + C.PAGES_ACCESS_PROFILE, + C.PAGES_ACCESS_NONE, + ): + raise NotImplementedError( + _("{} access is not implemented yet").format(access) + ) + self.access = access + self.dynamic = dynamic + if redirect is not None: + # only page access and name make sense in case of full redirection + # so we check that rendering methods/values are not set + if not all( + lambda x: x is not None + for x in (parse_url, prepare_render, render, template) + ): + raise ValueError( + _("you can't use full page redirection with other rendering" + "method, check self.page_redirect if you need to use them")) + self.redirect = redirect + else: + self.redirect = None + self.parse_url = parse_url + self.add_breadcrumb = add_breadcrumb + self.prepare_render = prepare_render + self.template = template + self.render_method = render + self.on_data_post = on_data_post + self.on_data = on_data + self.url_cache = url_cache + if access == C.PAGES_ACCESS_NONE: + # none pages just return a 404, no further check is needed + return + if template is not None and render is not None: + log.error(_("render and template methods can't be used at the same time")) + + # if not None, next rendering will be cached + # it must then contain a list of the the keys to use (without the page instance) + # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node] + self._do_cache = None + + def __str__(self): + return "LiberviaPage {name} at {url} (vhost: {vhost_root})".format( + name=self.name or "<anonymous>", url=self.url, vhost_root=self.vhost_root) + + @property + def named_pages(self): + return self.vhost_root.named_pages + + @property + def uri_callbacks(self): + return self.vhost_root.uri_callbacks + + @property + def pages_redirects(self): + return self.vhost_root.pages_redirects + + @property + def cached_urls(self): + return self.vhost_root.cached_urls + + @property + def main_menu(self): + return self.vhost_root.main_menu + + @property + def default_theme(self): + return self.vhost_root.default_theme + + + @property + def site_themes(self): + return self.vhost_root.site_themes + + @staticmethod + def create_page(host, meta_path, vhost_root, url_elts, replace_on_conflict=False): + """Create a LiberviaPage instance + + @param meta_path(Path): path to the page_meta.py file + @param vhost_root(resource.Resource): root resource of the virtual host + @param url_elts(list[unicode]): list of path element from root site to this page + @param replace_on_conflict(bool): same as for [LiberviaPage] + @return (tuple[dict, LiberviaPage]): tuple with: + - page_data: dict containing data of the page + - libervia_page: created resource + """ + dir_path = meta_path.parent + page_data = {"__name__": ".".join(["page"] + url_elts)} + # we don't want to force the presence of __init__.py + # so we use execfile instead of import. + # TODO: when moved to Python 3, __init__.py is not mandatory anymore + # so we can switch to import + exec(compile(open(meta_path, "rb").read(), meta_path, 'exec'), page_data) + return page_data, LiberviaPage( + host=host, + vhost_root=vhost_root, + root_dir=dir_path, + url="/" + "/".join(url_elts), + name=page_data.get("name"), + label=page_data.get("label"), + redirect=page_data.get("redirect"), + access=page_data.get("access"), + dynamic=page_data.get("dynamic", True), + parse_url=page_data.get("parse_url"), + add_breadcrumb=page_data.get("add_breadcrumb"), + prepare_render=page_data.get("prepare_render"), + render=page_data.get("render"), + template=page_data.get("template"), + on_data_post=page_data.get("on_data_post"), + on_data=page_data.get("on_data"), + url_cache=page_data.get("url_cache", False), + replace_on_conflict=replace_on_conflict + ) + + @staticmethod + def create_browser_data( + vhost_root, + resource: Optional[LiberviaPage], + browser_path: Path, + path_elts: Optional[List[str]], + engine: str = "brython" + ) -> None: + """create and store data for browser dynamic code""" + dyn_data = { + "path": browser_path, + "url_hash": ( + hashlib.sha256('/'.join(path_elts).encode()).hexdigest() + if path_elts is not None else None + ), + } + browser_meta_path = browser_path / C.PAGES_BROWSER_META_FILE + if browser_meta_path.is_file(): + with browser_meta_path.open() as f: + browser_meta = json.load(f) + utils.recursive_update(vhost_root.browser_modules, browser_meta) + if resource is not None: + utils.recursive_update(resource.dyn_data, browser_meta) + + init_path = browser_path / '__init__.py' + if init_path.is_file(): + vhost_root.browser_modules.setdefault( + engine, []).append(dyn_data) + if resource is not None: + resource.dyn_data[engine] = dyn_data + elif path_elts is None: + try: + next(browser_path.glob('*.py')) + except StopIteration: + # no python file, nothing for Brython + pass + else: + vhost_root.browser_modules.setdefault( + engine, []).append(dyn_data) + + + @classmethod + def import_pages(cls, host, vhost_root, root_path=None, _parent=None, _path=None, + _extra_pages=False): + """Recursively import Libervia pages + + @param host(Libervia): Libervia instance + @param vhost_root(LiberviaRootResource): root of this VirtualHost + @param root_path(Path, None): use this root path instead of vhost_root's one + Used to add default site pages to external sites + @param _parent(Resource, None): _parent page. Do not set yourself, this is for + internal use only + @param _path(list(unicode), None): current path. Do not set yourself, this is for + internal use only + @param _extra_pages(boolean): set to True when extra pages are used (i.e. + root_path is set). Do not set yourself, this is for internal use only + """ + if _path is None: + _path = [] + if _parent is None: + if root_path is None: + root_dir = vhost_root.site_path / C.PAGES_DIR + else: + root_dir = root_path / C.PAGES_DIR + _extra_pages = True + _parent = vhost_root + root_browser_path = root_dir / C.PAGES_BROWSER_DIR + if root_browser_path.is_dir(): + cls.create_browser_data(vhost_root, None, root_browser_path, None) + else: + root_dir = _parent.root_dir + + for d in os.listdir(root_dir): + dir_path = root_dir / d + if not dir_path.is_dir(): + continue + if _extra_pages and d in _parent.children: + log.debug(_("[{host_name}] {path} is already present, ignoring it") + .format(host_name=vhost_root.host_name, path='/'.join(_path+[d]))) + continue + meta_path = dir_path / C.PAGES_META_FILE + if meta_path.is_file(): + new_path = _path + [d] + try: + page_data, resource = cls.create_page( + host, meta_path, vhost_root, new_path) + except exceptions.ConflictError as e: + if _extra_pages: + # extra pages are discarded if there is already an existing page + continue + else: + raise e + _parent.putChild(str(d).encode(), resource) + log_msg = ("[{host_name}] Added /{path} page".format( + host_name=vhost_root.host_name, + path="[…]/".join(new_path))) + if _extra_pages: + log.debug(log_msg) + else: + log.info(log_msg) + if "uri_handlers" in page_data: + if not isinstance(page_data, dict): + log.error(_("uri_handlers must be a dict")) + else: + for uri_tuple, cb_name in page_data["uri_handlers"].items(): + if len(uri_tuple) != 2 or not isinstance(cb_name, str): + log.error(_("invalid uri_tuple")) + continue + if not _extra_pages: + log.info(_("setting {}/{} URIs handler") + .format(*uri_tuple)) + try: + cb = page_data[cb_name] + except KeyError: + log.error(_("missing {name} method to handle {1}/{2}") + .format(name=cb_name, *uri_tuple)) + continue + else: + resource.register_uri(uri_tuple, cb) + + LiberviaPage.import_pages( + host, vhost_root, _parent=resource, _path=new_path, + _extra_pages=_extra_pages) + # now we check if there is some code for browser + browser_path = dir_path / C.PAGES_BROWSER_DIR + if browser_path.is_dir(): + cls.create_browser_data(vhost_root, resource, browser_path, new_path) + + @classmethod + def on_file_change( + cls, + host, + file_path: FilePath, + flags: List[str], + site_root: LiberviaRootResource, + site_path: Path + ) -> None: + """Method triggered by file_watcher when something is changed in files + + This method is used in dev mode to reload pages when needed + @param file_path: path of the file which triggered the event + @param flags: human readable flags of the event (from + internet.inotify) + @param site_root: root of the site + @param site_path: absolute path of the site + """ + if flags == ['create']: + return + path = Path(file_path.path.decode()) + base_name = path.name + if base_name != "page_meta.py": + # we only handle libervia pages + return + + log.debug("{flags} event(s) received for {file_path}".format( + flags=", ".join(flags), file_path=file_path)) + + dir_path = path.parent + + if dir_path == site_path: + return + + if not site_path in dir_path.parents: + raise exceptions.InternalError("watched file should be in a subdirectory of site path") + + path_elts = list(dir_path.relative_to(site_path).parts) + + if path_elts[0] == C.PAGES_DIR: + # a page has been modified + del path_elts[0] + if not path_elts: + # we need at least one element to parse + return + # we retrieve page by starting from site root and finding each path element + parent = page = site_root + new_page = False + for idx, child_name in enumerate(path_elts): + child_name = child_name.encode() + try: + try: + page = page.original.children[child_name] + except AttributeError: + page = page.children[child_name] + except KeyError: + if idx != len(path_elts)-1: + # a page has been created in a subdir when one or more + # page_meta.py are missing on the way + log.warning(_("Can't create a page at {path}, missing parents") + .format(path=path)) + return + new_page = True + else: + if idx<len(path_elts)-1: + try: + parent = page.original + except AttributeError: + parent = page + + try: + # we (re)create a page with the new/modified code + __, resource = cls.create_page(host, path, site_root, path_elts, + replace_on_conflict=True) + if not new_page: + try: + resource.children = page.original.children + except AttributeError: + # FIXME: this .original handling madness is due to EncodingResourceWrapper + # EncodingResourceWrapper should probably be removed + resource.children = page.children + except Exception as e: + log.warning(_("Can't create page: {reason}").format(reason=e)) + else: + url_elt = path_elts[-1].encode() + if not new_page: + # the page was already existing, we remove it + del parent.children[url_elt] + # we can now add the new page + parent.putChild(url_elt, resource) + + # is there any browser data to create? + browser_path = resource.root_dir / C.PAGES_BROWSER_DIR + if browser_path.is_dir(): + cls.create_browser_data( + resource.vhost_root, + resource, + browser_path, + resource.url.split('/') + ) + + if new_page: + log.info(_("{page} created").format(page=resource)) + else: + log.info(_("{page} reloaded").format(page=resource)) + + def check_csrf(self, request): + session = self.host.get_session_data( + request, session_iface.IWebSession + ) + if session.profile is None: + # CSRF doesn't make sense when no user is logged + log.debug("disabling CSRF check because service profile is used") + return + csrf_token = session.csrf_token + given_csrf = request.getHeader("X-Csrf-Token") + if given_csrf is None: + try: + given_csrf = self.get_posted_data(request, "csrf_token") + except KeyError: + pass + if given_csrf is None or given_csrf != csrf_token: + log.warning( + _("invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format( + url=request.uri, ip=request.getClientIP() + ) + ) + self.page_error(request, C.HTTP_FORBIDDEN) + + def expose_to_scripts( + self, + request: server.Request, + **kwargs: str + ) -> None: + """Make a local variable available to page script as a global variable + + No check is done for conflicting name, use this carefully + """ + template_data = request.template_data + scripts = template_data.setdefault("scripts", utils.OrderedSet()) + for name, value in kwargs.items(): + if value is None: + value = "null" + elif isinstance(value, str): + # FIXME: workaround for subtype used by python-dbus (dbus.String) + # to be removed when we get rid of python-dbus + value = repr(str(value)) + else: + value = repr(value) + scripts.add(Script(content=f"var {name}={value};")) + + def register_uri(self, uri_tuple, get_uri_cb): + """Register a URI handler + + @param uri_tuple(tuple[unicode, unicode]): type or URIs handler + type/subtype as returned by tools/common/parse_xmpp_uri + or type/None to handle all subtypes + @param get_uri_cb(callable): method which take uri_data dict as only argument + and return absolute path with correct arguments or None if the page + can't handle this URL + """ + if uri_tuple in self.uri_callbacks: + log.info(_("{}/{} URIs are already handled, replacing by the new handler") + .format( *uri_tuple)) + self.uri_callbacks[uri_tuple] = (self, get_uri_cb) + + def config_get(self, key, default=None, value_type=None): + return self.host.config_get(self.vhost_root, key=key, default=default, + value_type=value_type) + + def get_build_path(self, session_data): + return session_data.cache_dir + self.vhost.site_name + + def get_page_by_name(self, name): + return self.vhost_root.get_page_by_name(name) + + def get_page_path_from_uri(self, uri): + return self.vhost_root.get_page_path_from_uri(uri) + + def get_page_redirect_url(self, request, page_name="login", url=None): + """generate URL for a page with redirect_url parameter set + + mainly used for login page with redirection to current page + @param request(server.Request): current HTTP request + @param page_name(unicode): name of the page to go + @param url(None, unicode): url to redirect to + None to use request path (i.e. current page) + @return (unicode): URL to use + """ + return "{root_url}?redirect_url={redirect_url}".format( + root_url=self.get_page_by_name(page_name).url, + redirect_url=urllib.parse.quote_plus(request.uri) + if url is None + else url.encode("utf-8"), + ) + + def get_url(self, *args: str, **kwargs: str) -> str: + """retrieve URL of the page set arguments + + @param *args: arguments to add to the URL as path elements empty or None + arguments will be ignored + @param **kwargs: query parameters + """ + url_args = [quote(a) for a in args if a] + + if self.name is not None and self.name in self.pages_redirects: + # we check for redirection + redirect_data = self.pages_redirects[self.name] + args_hash = tuple(args) + for limit in range(len(args), -1, -1): + current_hash = args_hash[:limit] + if current_hash in redirect_data: + url_base = redirect_data[current_hash] + remaining = args[limit:] + remaining_url = "/".join(remaining) + url = urllib.parse.urljoin(url_base, remaining_url) + break + else: + url = os.path.join(self.url, *url_args) + else: + url = os.path.join(self.url, *url_args) + + if kwargs: + encoded = urllib.parse.urlencode( + {k: v for k, v in kwargs.items()} + ) + url += f"?{encoded}" + + return self.host.check_redirection( + self.vhost_root, + url + ) + + def get_current_url(self, request): + """retrieve URL used to access this page + + @return(unicode): current URL + """ + # we get url in the following way (splitting request.path instead of using + # request.prepath) because request.prepath may have been modified by + # redirection (if redirection args have been specified), while path reflect + # the real request + + # we ignore empty path elements (i.e. double '/' or '/' at the end) + path_elts = [p for p in request.path.decode('utf-8').split("/") if p] + + if request.postpath: + if not request.postpath[-1]: + # we remove trailing slash + request.postpath = request.postpath[:-1] + if request.postpath: + # get_sub_page_url must return subpage from the point where + # the it is called, so we have to remove remanining + # path elements + path_elts = path_elts[: -len(request.postpath)] + + return "/" + "/".join(path_elts) + + def get_param_url(self, request, **kwargs): + """use URL of current request but modify the parameters in query part + + **kwargs(dict[str, unicode]): argument to use as query parameters + @return (unicode): constructed URL + """ + current_url = self.get_current_url(request) + if kwargs: + encoded = urllib.parse.urlencode( + {k: v for k, v in kwargs.items()} + ) + current_url = current_url + "?" + encoded + return current_url + + def get_sub_page_by_name(self, subpage_name, parent=None): + """retrieve a subpage and its path using its name + + @param subpage_name(unicode): name of the sub page + it must be a direct children of parent page + @param parent(LiberviaPage, None): parent page + None to use current page + @return (tuple[str, LiberviaPage]): page subpath and instance + @raise exceptions.NotFound: no page has been found + """ + if parent is None: + parent = self + for path, child in parent.children.items(): + try: + child_name = child.name + except AttributeError: + # LiberviaPages have a name, but maybe this is an other Resource + continue + if child_name == subpage_name: + return path.decode('utf-8'), child + raise exceptions.NotFound( + _("requested sub page has not been found ({subpage_name})").format( + subpage_name=subpage_name)) + + def get_sub_page_url(self, request, page_name, *args): + """retrieve a page in direct children and build its URL according to request + + request's current path is used as base (at current parsing point, + i.e. it's more prepath than path). + Requested page is checked in children and an absolute URL is then built + by the resulting combination. + This method is useful to construct absolute URLs for children instead of + using relative path, which may not work in subpages, and are linked to the + names of directories (i.e. relative URL will break if subdirectory is renamed + while get_sub_page_url won't as long as page_name is consistent). + Also, request.path is used, keeping real path used by user, + and potential redirections. + @param request(server.Request): current HTTP request + @param page_name(unicode): name of the page to retrieve + it must be a direct children of current page + @param *args(list[unicode]): arguments to add as path elements + if an arg is None, it will be ignored + @return (unicode): absolute URL to the sub page + """ + current_url = self.get_current_url(request) + path, child = self.get_sub_page_by_name(page_name) + return os.path.join( + "/", current_url, path, *[quote(a) for a in args if a is not None] + ) + + def get_url_by_names(self, named_path): + """Retrieve URL from pages names and arguments + + @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list + of tuples of 2 items: + - first item is page name + - second item is list of path arguments of this page + @return (unicode): URL to the requested page with given path arguments + @raise exceptions.NotFound: one of the page was not found + """ + current_page = None + path = [] + for page_name, page_args in named_path: + if current_page is None: + current_page = self.get_page_by_name(page_name) + path.append(current_page.get_url(*page_args)) + else: + sub_path, current_page = self.get_sub_page_by_name( + page_name, parent=current_page + ) + path.append(sub_path) + if page_args: + path.extend([quote(a) for a in page_args]) + return self.host.check_redirection(self.vhost_root, "/".join(path)) + + def get_url_by_path(self, *args): + """Generate URL by path + + this method as a similar effect as get_url_by_names, but it is more readable + by using SubPage to get pages instead of using tuples + @param *args: path element: + - if unicode, will be used as argument + - if util.SubPage instance, must be the name of a subpage + @return (unicode): generated path + """ + args = list(args) + if not args: + raise ValueError("You must specify path elements") + # root page is the one needed to construct the base of the URL + # if first arg is not a SubPage instance, we use current page + if not isinstance(args[0], SubPage): + root = self + else: + root = self.get_page_by_name(args.pop(0)) + # we keep track of current page to check subpage + current_page = root + url_elts = [] + arguments = [] + while True: + while args and not isinstance(args[0], SubPage): + arguments.append(quote(args.pop(0))) + if not url_elts: + url_elts.append(root.get_url(*arguments)) + else: + url_elts.extend(arguments) + if not args: + break + else: + path, current_page = current_page.get_sub_page_by_name(args.pop(0)) + arguments = [path] + return self.host.check_redirection(self.vhost_root, "/".join(url_elts)) + + def getChildWithDefault(self, path, request): + # we handle children ourselves + raise exceptions.InternalError( + "this method should not be used with LiberviaPage" + ) + + def next_path(self, request): + """get next URL path segment, and update request accordingly + + will move first segment of postpath in prepath + @param request(server.Request): current HTTP request + @return (unicode): unquoted segment + @raise IndexError: there is no segment left + """ + pathElement = request.postpath.pop(0) + request.prepath.append(pathElement) + return urllib.parse.unquote(pathElement.decode('utf-8')) + + def _filter_path_value(self, value, handler, name, request): + """Modify a path value according to handler (see [get_path_args])""" + if handler in ("@", "@jid") and value == "@": + value = None + + if handler in ("", "@"): + if value is None: + return "" + elif handler in ("jid", "@jid"): + if value: + try: + return jid.JID(value) + except (RuntimeError, jid.InvalidFormat): + log.warning(_("invalid jid argument: {value}").format(value=value)) + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + return "" + else: + return handler(self, value, name, request) + + return value + + def get_path_args(self, request, names, min_args=0, **kwargs): + """get several path arguments at once + + Arguments will be put in request data. + Missing arguments will have None value + @param names(list[unicode]): list of arguments to get + @param min_args(int): if less than min_args are found, PageError is used with + C.HTTP_BAD_REQUEST + Use 0 to ignore + @param **kwargs: special value or optional callback to use for arguments + names of the arguments must correspond to those in names + special values may be: + - '': use empty string instead of None when no value is specified + - '@': if value of argument is empty or '@', empty string will be used + - 'jid': value must be converted to jid.JID if it exists, else empty + string is used + - '@jid': if value of arguments is empty or '@', empty string will be + used, else it will be converted to jid + """ + data = self.get_r_data(request) + + for idx, name in enumerate(names): + if name[0] == "*": + value = data[name[1:]] = [] + while True: + try: + value.append(self.next_path(request)) + except IndexError: + idx -= 1 + break + else: + idx += 1 + else: + try: + value = data[name] = self.next_path(request) + except IndexError: + data[name] = None + idx -= 1 + break + + values_count = idx + 1 + if values_count < min_args: + log.warning(_("Missing arguments in URL (got {count}, expected at least " + "{min_args})").format(count=values_count, min_args=min_args)) + self.page_error(request, C.HTTP_BAD_REQUEST) + + for name in names[values_count:]: + data[name] = None + + for name, handler in kwargs.items(): + if name[0] == "*": + data[name] = [ + self._filter_path_value(v, handler, name, request) for v in data[name] + ] + else: + data[name] = self._filter_path_value(data[name], handler, name, request) + + ## Pagination/Filtering ## + + def get_pubsub_extra(self, request, page_max=10, params=None, extra=None, + order_by=C.ORDER_BY_CREATION): + """Set extra dict to retrieve PubSub items corresponding to URL parameters + + Following parameters are used: + - after: set rsm_after with ID of item + - before: set rsm_before with ID of item + @param request(server.Request): current HTTP request + @param page_max(int): required number of items per page + @param params(None, dict[unicode, list[unicode]]): params as returned by + self.get_all_posted_data. + None to parse URL automatically + @param extra(None, dict): extra dict to use, or None to use a new one + @param order_by(unicode, None): key to order by + None to not specify order + @return (dict): fill extra data + """ + if params is None: + params = self.get_all_posted_data(request, multiple=False) + if extra is None: + extra = {} + else: + assert not {"rsm_max", "rsm_after", "rsm_before", + C.KEY_ORDER_BY}.intersection(list(extra.keys())) + extra["rsm_max"] = params.get("page_max", str(page_max)) + if order_by is not None: + extra[C.KEY_ORDER_BY] = order_by + if 'after' in params: + extra['rsm_after'] = params['after'] + elif 'before' in params: + extra['rsm_before'] = params['before'] + else: + # RSM returns list in order (oldest first), but we want most recent first + # so we start by the end + extra['rsm_before'] = "" + return extra + + def set_pagination(self, request: server.Request, pubsub_data: dict) -> None: + """Add to template_data if suitable + + "previous_page_url" and "next_page_url" will be added using respectively + "before" and "after" URL parameters + @param request: current HTTP request + @param pubsub_data: pubsub metadata + """ + template_data = request.template_data + extra = {} + try: + rsm = pubsub_data["rsm"] + last_id = rsm["last"] + except KeyError: + # no pagination available + return + + # if we have a search query, we must keep it + search = self.get_posted_data(request, 'search', raise_on_missing=False) + if search is not None: + extra['search'] = search.strip() + + # same for page_max + page_max = self.get_posted_data(request, 'page_max', raise_on_missing=False) + if page_max is not None: + extra['page_max'] = page_max + + if rsm.get("index", 1) > 0: + # We only show previous button if it's not the first page already. + # If we have no index, we default to display the button anyway + # as we can't know if we are on the first page or not. + first_id = rsm["first"] + template_data['previous_page_url'] = self.get_param_url( + request, before=first_id, **extra) + if not pubsub_data["complete"]: + # we also show the page next button if complete is None because we + # can't know where we are in the feed in this case. + template_data['next_page_url'] = self.get_param_url( + request, after=last_id, **extra) + + + ## Cache handling ## + + def _set_cache_headers(self, request, cache): + """Set ETag and Last-Modified HTTP headers, used for caching""" + request.setHeader("ETag", cache.hash) + last_modified = self.host.get_http_date(cache.created) + request.setHeader("Last-Modified", last_modified) + + def _check_cache_headers(self, request, cache): + """Check if a cache condition is set on the request + + if condition is valid, C.HTTP_NOT_MODIFIED is returned + """ + etag_match = request.getHeader("If-None-Match") + if etag_match is not None: + if cache.hash == etag_match: + self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True) + else: + modified_match = request.getHeader("If-Modified-Since") + if modified_match is not None: + modified = date_utils.date_parse(modified_match) + if modified >= int(cache.created): + self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True) + + def check_cache_subscribe_cb(self, sub_id, service, node): + self.cache_pubsub_sub.add((service, node, sub_id)) + + def check_cache_subscribe_eb(self, failure_, service, node): + log.warning(_("Can't subscribe to node: {msg}").format(msg=failure_)) + # FIXME: cache must be marked as unusable here + + def ps_node_watch_add_eb(self, failure_, service, node): + log.warning(_("Can't add node watched: {msg}").format(msg=failure_)) + + def use_cache(self, request: server.Request) -> bool: + """Indicate if the cache should be used + + test request header to see if it is requested to skip the cache + @return: True if cache should be used + """ + return request.getHeader('cache-control') != 'no-cache' + + def check_cache(self, request, cache_type, **kwargs): + """check if a page is in cache and return cached version if suitable + + this method may perform extra operation to handle cache (e.g. subscribing to a + pubsub node) + @param request(server.Request): current HTTP request + @param cache_type(int): on of C.CACHE_* const. + @param **kwargs: args according to cache_type: + C.CACHE_PUBSUB: + service: pubsub service + node: pubsub node + short: short name of feature (needed if node is empty to find namespace) + + """ + if request.postpath: + # we are not on the final page, no need to go further + return + + if request.uri != request.path: + # we don't cache page with query arguments as there can be a lot of variants + # influencing page results (e.g. search terms) + log.debug("ignoring cache due to query arguments") + + no_cache = not self.use_cache(request) + + profile = self.get_profile(request) or C.SERVICE_PROFILE + + if cache_type == C.CACHE_PUBSUB: + service, node = kwargs["service"], kwargs["node"] + if not node: + try: + short = kwargs["short"] + node = self.host.ns_map[short] + except KeyError: + log.warning(_('Can\'t use cache for empty node without namespace ' + 'set, please ensure to set "short" and that it is ' + 'registered')) + return + if profile != C.SERVICE_PROFILE: + # only service profile is cached for now + return + session_data = self.host.get_session_data(request, session_iface.IWebSession) + locale = session_data.locale + if locale == C.DEFAULT_LOCALE: + # no need to duplicate cache here + locale = None + try: + cache = (self.cache[profile][cache_type][service][node] + [self.vhost_root][request.uri][locale][self]) + except KeyError: + # no cache yet, let's subscribe to the pubsub node + d1 = self.host.bridge_call( + "ps_subscribe", service.full(), node, "", profile + ) + d1.addCallback(self.check_cache_subscribe_cb, service, node) + d1.addErrback(self.check_cache_subscribe_eb, service, node) + d2 = self.host.bridge_call("ps_node_watch_add", service.full(), node, profile) + d2.addErrback(self.ps_node_watch_add_eb, service, node) + self._do_cache = [self, profile, cache_type, service, node, + self.vhost_root, request.uri, locale] + # we don't return the Deferreds as it is not needed to wait for + # the subscription to continue with page rendering + return + else: + if no_cache: + del (self.cache[profile][cache_type][service][node] + [self.vhost_root][request.uri][locale][self]) + log.debug(f"cache removed for {self}") + return + + else: + raise exceptions.InternalError("Unknown cache_type") + log.debug("using cache for {page}".format(page=self)) + cache.last_access = time.time() + self._set_cache_headers(request, cache) + self._check_cache_headers(request, cache) + request.write(cache.rendered) + request.finish() + raise failure.Failure(exceptions.CancelError("cache is used")) + + def _cache_url(self, request, profile): + self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request) + + @classmethod + def on_node_event(cls, host, service, node, event_type, items, profile): + """Invalidate cache for all pages linked to this node""" + try: + cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node] + except KeyError: + log.info(_( + "Removing subscription for {service}/{node}: " + "the page is not cached").format(service=service, node=node)) + d1 = host.bridge_call("ps_unsubscribe", service, node, profile) + d1.addErrback( + lambda failure_: log.warning( + _("Can't unsubscribe from {service}/{node}: {msg}").format( + service=service, node=node, msg=failure_))) + d2 = host.bridge_call("ps_node_watch_add", service, node, profile) + # TODO: check why the page is not in cache, remove subscription? + d2.addErrback( + lambda failure_: log.warning( + _("Can't remove watch for {service}/{node}: {msg}").format( + service=service, node=node, msg=failure_))) + else: + cache.clear() + + # identities + + async def fill_missing_identities( + self, + request: server.Request, + entities: List[Union[str, jid.JID, None]], + ) -> None: + """Check if all entities have an identity cache, get missing ones from backend + + @param request: request with a plugged profile + @param entities: entities to check, None or empty strings will be filtered + """ + entities = {str(e) for e in entities if e} + profile = self.get_profile(request) or C.SERVICE_PROFILE + identities = self.host.get_session_data( + request, + session_iface.IWebSession + ).identities + for e in entities: + if e not in identities: + id_raw = await self.host.bridge_call( + 'identity_get', e, [], True, profile) + identities[e] = data_format.deserialise(id_raw) + + # signals, server => browser communication + + def delegate_to_resource(self, request, resource): + """continue workflow with Twisted Resource""" + buf = resource.render(request) + if buf == server.NOT_DONE_YET: + pass + else: + request.write(buf) + request.finish() + raise failure.Failure(exceptions.CancelError("resource delegation")) + + def http_redirect(self, request, url): + """redirect to an URL using HTTP redirection + + @param request(server.Request): current HTTP request + @param url(unicode): url to redirect to + """ + web_util.redirectTo(url.encode("utf-8"), request) + request.finish() + raise failure.Failure(exceptions.CancelError("HTTP redirection is used")) + + def redirect_or_continue(self, request, redirect_arg="redirect_url"): + """Helper method to redirect a page to an url given as arg + + if the arg is not present, the page will continue normal workflow + @param request(server.Request): current HTTP request + @param redirect_arg(unicode): argument to use to get redirection URL + @interrupt: redirect the page to requested URL + @interrupt page_error(C.HTTP_BAD_REQUEST): empty or non local URL is used + """ + redirect_arg = redirect_arg.encode('utf-8') + try: + url = request.args[redirect_arg][0].decode('utf-8') + except (KeyError, IndexError): + pass + else: + # a redirection is requested + if not url or url[0] != "/": + # we only want local urls + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + self.http_redirect(request, url) + + def page_redirect(self, page_path, request, skip_parse_url=True, path_args=None): + """redirect a page to a named page + + the workflow will continue with the workflow of the named page, + skipping named page's parse_url method if it exist. + If you want to do a HTTP redirection, use http_redirect + @param page_path(unicode): path to page (elements are separated by "/"): + if path starts with a "/": + path is a full path starting from root + else: + - first element is name as registered in name variable + - following element are subpages path + e.g.: "blog" redirect to page named "blog" + "blog/atom.xml" redirect to atom.xml subpage of "blog" + "/common/blog/atom.xml" redirect to the page at the given full path + @param request(server.Request): current HTTP request + @param skip_parse_url(bool): if True, parse_url method on redirect page will be + skipped + @param path_args(list[unicode], None): path arguments to use in redirected page + @raise KeyError: there is no known page with this name + """ + # FIXME: render non LiberviaPage resources + path = page_path.rstrip("/").split("/") + if not path[0]: + redirect_page = self.vhost_root + else: + redirect_page = self.named_pages[path[0]] + + for subpage in path[1:]: + subpage = subpage.encode('utf-8') + if redirect_page is self.vhost_root: + redirect_page = redirect_page.children[subpage] + else: + redirect_page = redirect_page.original.children[subpage] + + if path_args is not None: + args = [quote(a).encode() for a in path_args] + request.postpath = args + request.postpath + + if self._do_cache: + # if cache is needed, it will be handled by final page + redirect_page._do_cache = self._do_cache + self._do_cache = None + + defer.ensureDeferred( + redirect_page.render_page(request, skip_parse_url=skip_parse_url) + ) + raise failure.Failure(exceptions.CancelError("page redirection is used")) + + def page_error(self, request, code=C.HTTP_NOT_FOUND, no_body=False): + """generate an error page and terminate the request + + @param request(server.Request): HTTP request + @param core(int): error code to use + @param no_body: don't write body if True + """ + if self._do_cache is not None: + # we don't want to cache error pages + self._do_cache = None + request.setResponseCode(code) + if no_body: + request.finish() + else: + template = "error/" + str(code) + ".html" + template_data = request.template_data + session_data = self.host.get_session_data(request, session_iface.IWebSession) + if session_data.locale is not None: + template_data['locale'] = session_data.locale + if self.vhost_root.site_name: + template_data['site'] = self.vhost_root.site_name + + rendered = self.host.renderer.render( + template, + theme=session_data.theme or self.default_theme, + media_path=f"/{C.MEDIA_DIR}", + build_path=f"/{C.BUILD_DIR}/", + site_themes=self.site_themes, + error_code=code, + **template_data + ) + + self.write_data(rendered, request) + raise failure.Failure(exceptions.CancelError("error page is used")) + + def write_data(self, data, request): + """write data to transport and finish the request""" + if data is None: + self.page_error(request) + data_encoded = data.encode("utf-8") + + if self._do_cache is not None: + redirected_page = self._do_cache.pop(0) + cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache) + page_cache = cache[redirected_page] = CachePage(data_encoded) + self._set_cache_headers(request, page_cache) + log.debug(_("{page} put in cache for [{profile}]") + .format( page=self, profile=self._do_cache[0])) + self._do_cache = None + self._check_cache_headers(request, page_cache) + + try: + request.write(data_encoded) + except AttributeError: + log.warning(_("Can't write page, the request has probably been cancelled " + "(browser tab closed or reloaded)")) + return + request.finish() + + def _subpages_handler(self, request): + """render subpage if suitable + + this method checks if there is still an unmanaged part of the path + and check if it corresponds to a subpage. If so, it render the subpage + else it render a NoResource. + If there is no unmanaged part of the segment, current page workflow is pursued + """ + if request.postpath: + subpage = self.next_path(request).encode('utf-8') + try: + child = self.children[subpage] + except KeyError: + self.page_error(request) + else: + child.render(request) + raise failure.Failure(exceptions.CancelError("subpage page is used")) + + def _prepare_dynamic(self, request): + session_data = self.host.get_session_data(request, session_iface.IWebSession) + # we need to activate dynamic page + # we set data for template, and create/register token + # socket_token = str(uuid.uuid4()) + socket_url = self.host.get_websocket_url(request) + # as for CSRF, it is important to not let the socket token if we use the service + # profile, as those pages can be cached, and then the token leaked. + socket_token = '' if session_data.profile is None else session_data.ws_token + socket_debug = C.bool_const(self.host.debug) + request.template_data["websocket"] = WebsocketMeta( + socket_url, socket_token, socket_debug + ) + # we will keep track of handlers to remove + request._signals_registered = [] + # we will cache registered signals until socket is opened + request._signals_cache = [] + + def _render_template(self, request): + template_data = request.template_data + + # if confirm variable is set in case of successfuly data post + session_data = self.host.get_session_data(request, session_iface.IWebSession) + template_data['identities'] = session_data.identities + if session_data.pop_page_flag(self, C.FLAG_CONFIRM): + template_data["confirm"] = True + notifs = session_data.pop_page_notifications(self) + if notifs: + template_data["notifications"] = notifs + if session_data.jid is not None: + template_data["own_jid"] = session_data.jid + if session_data.locale is not None: + template_data['locale'] = session_data.locale + if session_data.guest: + template_data['guest_session'] = True + if self.vhost_root.site_name: + template_data['site'] = self.vhost_root.site_name + if self.dyn_data: + for data in self.dyn_data.values(): + try: + scripts = data['scripts'] + except KeyError: + pass + else: + template_data.setdefault('scripts', utils.OrderedSet()).update(scripts) + template_data.update(data.get('template', {})) + data_common = self.vhost_root.dyn_data_common + common_scripts = data_common['scripts'] + if common_scripts: + template_data.setdefault('scripts', utils.OrderedSet()).update(common_scripts) + if "template" in data_common: + for key, value in data_common["template"].items(): + if key not in template_data: + template_data[key] = value + + theme = session_data.theme or self.default_theme + self.expose_to_scripts( + request, + cache_path=session_data.cache_dir, + templates_root_url=str(self.vhost_root.get_front_url(theme)), + profile=session_data.profile) + + uri = request.uri.decode() + try: + template_data["current_page"] = next( + m[0] for m in self.main_menu if uri.startswith(m[1]) + ) + except StopIteration: + pass + + return self.host.renderer.render( + self.template, + theme=theme, + site_themes=self.site_themes, + page_url=self.get_url(), + media_path=f"/{C.MEDIA_DIR}", + build_path=f"/{C.BUILD_DIR}/", + cache_path=session_data.cache_dir, + main_menu=self.main_menu, + **template_data) + + def _on_data_post_redirect(self, ret, request): + """called when page's on_data_post has been done successfuly + + This will do a Post/Redirect/Get pattern. + this method redirect to the same page or to request.data['post_redirect_page'] + post_redirect_page can be either a page or a tuple with page as first item, then + a list of unicode arguments to append to the url. + if post_redirect_page is not used, initial request.uri (i.e. the same page as + where the data have been posted) will be used for redirection. + HTTP status code "See Other" (303) is used as it is the recommanded code in + this case. + @param ret(None, unicode, iterable): on_data_post return value + see LiberviaPage.__init__ on_data_post docstring + """ + if ret is None: + ret = () + elif isinstance(ret, str): + ret = (ret,) + else: + ret = tuple(ret) + raise NotImplementedError( + _("iterable in on_data_post return value is not used yet") + ) + session_data = self.host.get_session_data(request, session_iface.IWebSession) + request_data = self.get_r_data(request) + if "post_redirect_page" in request_data: + redirect_page_data = request_data["post_redirect_page"] + if isinstance(redirect_page_data, tuple): + redirect_page = redirect_page_data[0] + redirect_page_args = redirect_page_data[1:] + redirect_uri = redirect_page.get_url(*redirect_page_args) + else: + redirect_page = redirect_page_data + redirect_uri = redirect_page.url + else: + redirect_page = self + redirect_uri = request.uri + + if not C.POST_NO_CONFIRM in ret: + session_data.set_page_flag(redirect_page, C.FLAG_CONFIRM) + request.setResponseCode(C.HTTP_SEE_OTHER) + request.setHeader(b"location", redirect_uri) + request.finish() + raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used")) + + async def _on_data_post(self, request): + self.check_csrf(request) + try: + ret = await as_deferred(self.on_data_post, self, request) + except exceptions.DataError as e: + # something is wrong with the posted data, we re-display the page with a + # warning notification + session_data = self.host.get_session_data(request, session_iface.IWebSession) + session_data.set_page_notification(self, str(e), C.LVL_WARNING) + request.setResponseCode(C.HTTP_SEE_OTHER) + request.setHeader("location", request.uri) + request.finish() + raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used")) + else: + if ret != "continue": + self._on_data_post_redirect(ret, request) + + def get_posted_data( + self, + request: server.Request, + keys, + multiple: bool = False, + raise_on_missing: bool = True, + strip: bool = True + ): + """Get data from a POST request or from URL's query part and decode it + + @param request: request linked to the session + @param keys(unicode, iterable[unicode]): name of the value(s) to get + unicode to get one value + iterable to get more than one + @param multiple: True if multiple values are possible/expected + if False, the first value is returned + @param raise_on_missing: raise KeyError on missing key if True + else use None for missing values + @param strip: if True, apply "strip()" on values + @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): + values received for this(these) key(s) + @raise KeyError: one specific key has been requested, and it is missing + """ + # FIXME: request.args is already unquoting the value, it seems we are doing + # double unquote + if isinstance(keys, str): + keys = [keys] + + keys = [k.encode('utf-8') for k in keys] + + ret = [] + for key in keys: + gen = (urllib.parse.unquote(v.decode("utf-8")) + for v in request.args.get(key, [])) + if multiple: + ret.append(gen.strip() if strip else gen) + else: + try: + v = next(gen) + except StopIteration: + if raise_on_missing: + raise KeyError(key) + else: + ret.append(None) + else: + ret.append(v.strip() if strip else v) + + if len(keys) == 1: + return ret[0] + else: + return ret + + def get_all_posted_data(self, request, except_=(), multiple=True): + """get all posted data + + @param request(server.Request): request linked to the session + @param except_(iterable[unicode]): key of values to ignore + csrf_token will always be ignored + @param multiple(bool): if False, only the first values are returned + @return (dict[unicode, list[unicode]]): post values + """ + except_ = tuple(except_) + ("csrf_token",) + ret = {} + for key, values in request.args.items(): + key = key.decode('utf-8') + key = urllib.parse.unquote(key) + if key in except_: + continue + values = [v.decode('utf-8') for v in values] + if not multiple: + ret[key] = urllib.parse.unquote(values[0]) + else: + ret[key] = [urllib.parse.unquote(v) for v in values] + return ret + + def get_profile(self, request): + """Helper method to easily get current profile + + @return (unicode, None): current profile + None if no profile session is started + """ + web_session = self.host.get_session_data(request, session_iface.IWebSession) + return web_session.profile + + def get_jid(self, request): + """Helper method to easily get current jid + + @return: current jid + """ + web_session = self.host.get_session_data(request, session_iface.IWebSession) + return web_session.jid + + + def get_r_data(self, request): + """Helper method to get request data dict + + this dictionnary if for the request only, it is not saved in session + It is mainly used to pass data between pages/methods called during request + workflow + @return (dict): request data + """ + try: + return request.data + except AttributeError: + request.data = {} + return request.data + + def get_page_data(self, request, key): + """Helper method to retrieve reload resistant data""" + web_session = self.host.get_session_data(request, session_iface.IWebSession) + return web_session.get_page_data(self, key) + + def set_page_data(self, request, key, value): + """Helper method to set reload resistant data""" + web_session = self.host.get_session_data(request, session_iface.IWebSession) + return web_session.set_page_data(self, key, value) + + def handle_search(self, request, extra): + """Manage Full-Text Search + + Check if "search" query argument is present, and add MAM filter for it if + necessary. + If used, the "search" variable will also be available in template data, thus + frontend can display some information about it. + """ + search = self.get_posted_data(request, 'search', raise_on_missing=False) + if search is not None: + search = search.strip() + if search: + try: + extra[f'mam_filter_{self.host.ns_map["fulltextmam"]}'] = search + except KeyError: + log.warning(_("Full-text search is not available")) + else: + request.template_data['search'] = search + + def _check_access(self, request): + """Check access according to self.access + + if access is not granted, show a HTTP_FORBIDDEN page_error and stop request, + else return data (so it can be inserted in deferred chain + """ + if self.access == C.PAGES_ACCESS_PUBLIC: + pass + elif self.access == C.PAGES_ACCESS_PROFILE: + profile = self.get_profile(request) + if not profile: + # registration allowed, we redirect to login page + login_url = self.get_page_redirect_url(request) + self.http_redirect(request, login_url) + + def set_best_locale(self, request): + """Guess the best locale when it is not specified explicitly by user + + This method will check "accept-language" header, and set locale to first + matching value with available translations. + """ + accept_language = request.getHeader("accept-language") + if not accept_language: + return + accepted = [a.strip() for a in accept_language.split(',')] + available = [str(l) for l in self.host.renderer.translations] + for lang in accepted: + lang = lang.split(';')[0].strip().lower() + if not lang: + continue + for a in available: + if a.lower().startswith(lang): + session_data = self.host.get_session_data(request, + session_iface.IWebSession) + session_data.locale = a + return + + async def render_page(self, request, skip_parse_url=False): + """Main method to handle the workflow of a LiberviaPage""" + # template_data are the variables passed to template + if not hasattr(request, "template_data"): + # if template_data doesn't exist, it's the beginning of the request workflow + # so we fill essential data + session_data = self.host.get_session_data(request, session_iface.IWebSession) + profile = session_data.profile + request.template_data = { + "profile": profile, + # it's important to not add CSRF token and session uuid if service profile + # is used because the page may be cached, and the token then leaked + "csrf_token": "" if profile is None else session_data.csrf_token, + "session_uuid": "public" if profile is None else session_data.uuid, + "breadcrumbs": [] + } + + # XXX: here is the code which need to be executed once + # at the beginning of the request hanling + if request.postpath and not request.postpath[-1]: + # we don't differenciate URLs finishing with '/' or not + del request.postpath[-1] + + # i18n + key_lang = C.KEY_LANG.encode() + if key_lang in request.args: + try: + locale = request.args.pop(key_lang)[0].decode() + except IndexError: + log.warning("empty lang received") + else: + if "/" in locale: + # "/" is refused because locale may sometime be used to access + # path, if localised documents are available for instance + log.warning(_('illegal char found in locale ("/"), hack ' + 'attempt? locale={locale}').format(locale=locale)) + locale = None + session_data.locale = locale + + # if locale is not specified, we try to find one requested by browser + if session_data.locale is None: + self.set_best_locale(request) + + # theme + key_theme = C.KEY_THEME.encode() + if key_theme in request.args: + theme = request.args.pop(key_theme)[0].decode() + if key_theme != session_data.theme: + if theme not in self.site_themes: + log.warning(_( + "Theme {theme!r} doesn't exist for {vhost}" + .format(theme=theme, vhost=self.vhost_root))) + else: + session_data.theme = theme + try: + + try: + self._check_access(request) + + if self.redirect is not None: + self.page_redirect(self.redirect, request, skip_parse_url=False) + + if self.parse_url is not None and not skip_parse_url: + if self.url_cache: + profile = self.get_profile(request) + try: + cache_url = self.cached_urls[profile][request.uri] + except KeyError: + # no cache for this URI yet + # we do normal URL parsing, and then the cache + await as_deferred(self.parse_url, self, request) + self._cache_url(request, profile) + else: + log.debug(f"using URI cache for {self}") + cache_url.use(request) + else: + await as_deferred(self.parse_url, self, request) + + if self.add_breadcrumb is None: + label = ( + self.label + or self.name + or self.url[self.url.rfind('/')+1:] + ) + breadcrumb = { + "url": self.url, + "label": label.title(), + } + request.template_data["breadcrumbs"].append(breadcrumb) + else: + await as_deferred( + self.add_breadcrumb, + self, + request, + request.template_data["breadcrumbs"] + ) + + self._subpages_handler(request) + + if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST): + # only HTTP GET and POST are handled so far + self.page_error(request, C.HTTP_BAD_REQUEST) + + if request.method == C.HTTP_METHOD_POST: + if self.on_data_post == 'continue': + pass + elif self.on_data_post is None: + # if we don't have on_data_post, the page was not expecting POST + # so we return an error + self.page_error(request, C.HTTP_BAD_REQUEST) + else: + await self._on_data_post(request) + # by default, POST follow normal behaviour after on_data_post is called + # this can be changed by a redirection or other method call in on_data_post + + if self.dynamic: + self._prepare_dynamic(request) + + if self.prepare_render: + await as_deferred(self.prepare_render, self, request) + + if self.template: + rendered = self._render_template(request) + elif self.render_method: + rendered = await as_deferred(self.render_method, self, request) + else: + raise exceptions.InternalError( + "No method set to render page, please set a template or use a " + "render method" + ) + + self.write_data(rendered, request) + + except failure.Failure as f: + # we have to unpack the Failure to catch the right Exception + raise f.value + + except exceptions.CancelError: + pass + except BridgeException as e: + if e.condition == 'not-allowed': + log.warning("not allowed exception catched") + self.page_error(request, C.HTTP_FORBIDDEN) + elif e.condition == 'item-not-found' or e.classname == 'NotFound': + self.page_error(request, C.HTTP_NOT_FOUND) + elif e.condition == 'remote-server-not-found': + self.page_error(request, C.HTTP_NOT_FOUND) + elif e.condition == 'forbidden': + if self.get_profile(request) is None: + log.debug("access forbidden, we're redirecting to log-in page") + self.http_redirect(request, self.get_page_redirect_url(request)) + else: + self.page_error(request, C.HTTP_FORBIDDEN) + else: + log.error( + _("Uncatched bridge exception for HTTP request on {url}: {e}\n" + "page name: {name}\npath: {path}\nURL: {full_url}\n{tb}") + .format( + url=self.url, + e=e, + name=self.name or "", + path=self.root_dir, + full_url=request.URLPath(), + tb=traceback.format_exc(), + ) + ) + try: + self.page_error(request, C.HTTP_INTERNAL_ERROR) + except exceptions.CancelError: + pass + except Exception as e: + log.error( + _("Uncatched error for HTTP request on {url}: {e}\npage name: " + "{name}\npath: {path}\nURL: {full_url}\n{tb}") + .format( + url=self.url, + e=e, + name=self.name or "", + path=self.root_dir, + full_url=request.URLPath(), + tb=traceback.format_exc(), + ) + ) + try: + self.page_error(request, C.HTTP_INTERNAL_ERROR) + except exceptions.CancelError: + pass + + def render_GET(self, request): + defer.ensureDeferred(self.render_page(request)) + return server.NOT_DONE_YET + + def render_POST(self, request): + defer.ensureDeferred(self.render_page(request)) + return server.NOT_DONE_YET
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/pages_tools.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +# Libervia Web frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Helper methods for common operations on pages""" + +from twisted.internet import defer +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import data_format +from libervia.web.server.constants import Const as C + + +log = getLogger(__name__) + + +def deserialise(comments_data_s): + return data_format.deserialise(comments_data_s) + + +def retrieve_comments(self, service, node, profile, pass_exceptions=True): + """Retrieve comments from server and convert them to data objects + + @param service(unicode): service holding the comments + @param node(unicode): node to retrieve + @param profile(unicode): profile of the user willing to find comments + @param pass_exceptions(bool): if True bridge exceptions will be ignored but logged + else exception will be raised + """ + try: + d = self.host.bridge_call( + "mb_get", service, node, C.NO_LIMIT, [], data_format.serialise({}), profile + ) + except Exception as e: + if not pass_exceptions: + raise e + else: + log.warning( + _("Can't get comments at {node} (service: {service}): {msg}").format( + service=service, node=node, msg=e + ) + ) + return defer.succeed([]) + + d.addCallback(deserialise) + return d
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/proxy.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +from twisted.web import proxy +from twisted.python.compat import urlquote +from twisted.internet import address +from libervia.backend.core.log import getLogger +from libervia.web.server.constants import Const as C + +log = getLogger(__name__) + + + +class SatProxyClient(proxy.ProxyClient): + + def handleHeader(self, key, value): + if key.lower() == b"x-frame-options": + value = b"sameorigin" + elif key.lower() == b"content-security-policy": + value = value.replace(b"frame-ancestors 'none'", b"frame-ancestors 'self'") + + super().handleHeader(key, value) + + +class SatProxyClientFactory(proxy.ProxyClientFactory): + protocol = SatProxyClient + + +class SatReverseProxyResource(proxy.ReverseProxyResource): + """Resource Proxy rewritting headers to allow embedding in iframe on same domain""" + proxyClientFactoryClass = SatProxyClientFactory + + def getChild(self, path, request): + return SatReverseProxyResource( + self.host, self.port, + self.path + b'/' + urlquote(path, safe=b"").encode('utf-8'), + self.reactor + ) + + def render(self, request): + # Forwarded and X-Forwarded-xxx headers can be set + # if we have behind an other proxy + if ((not request.getHeader(C.H_FORWARDED) + and not request.getHeader(C.H_X_FORWARDED_HOST))): + forwarded_data = [] + addr = request.getClientAddress() + if ((isinstance(addr, address.IPv4Address) + or isinstance(addr, address.IPv6Address))): + request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_FOR, [addr.host]) + forwarded_data.append(f"for={addr.host}") + host = request.getHeader("host") + if host is None: + port = request.getHost().port + hostname = request.getRequestHostname() + host = hostname if port in (80, 443) else f"{hostname}:{port}" + request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_HOST, [host]) + forwarded_data.append(f"host={host}") + proto = "https" if request.isSecure() else "http" + request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_PROTO, [proto]) + forwarded_data.append(f"proto={proto}") + request.requestHeaders.setRawHeaders( + C.H_FORWARDED, [";".join(forwarded_data)] + ) + + return super().render(request)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/resources.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,708 @@ +#!/usr/bin/env python3 + +# Libervia Web +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import os.path +from pathlib import Path +import urllib.error +import urllib.parse +import urllib.request + +from twisted.internet import defer +from twisted.web import server +from twisted.web import static +from twisted.web import resource as web_resource + +from libervia.web.server.constants import Const as C +from libervia.web.server.utils import quote +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import D_, _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import uri as common_uri +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common.utils import OrderedSet, recursive_update + +from . import proxy + +log = getLogger(__name__) + + +class ProtectedFile(static.File): + """A static.File class which doesn't show directory listing""" + + def __init__(self, path, *args, **kwargs): + if "defaultType" not in kwargs and len(args) < 2: + # defaultType is second positional argument, and Twisted uses it + # in File.createSimilarFile, so we set kwargs only if it is missing + # in kwargs and it is not in a positional argument + kwargs["defaultType"] = "application/octet-stream" + super(ProtectedFile, self).__init__(str(path), *args, **kwargs) + + def directoryListing(self): + return web_resource.NoResource() + + + def getChild(self, path, request): + return super().getChild(path, request) + + def getChildWithDefault(self, path, request): + return super().getChildWithDefault(path, request) + + def getChildForRequest(self, request): + return super().getChildForRequest(request) + + +class LiberviaRootResource(ProtectedFile): + """Specialized resource for Libervia root + + handle redirections declared in libervia.conf + """ + + def __init__(self, host, host_name, site_name, site_path, *args, **kwargs): + ProtectedFile.__init__(self, *args, **kwargs) + self.host = host + self.host_name = host_name + self.site_name = site_name + self.site_path = Path(site_path) + self.default_theme = self.config_get('theme') + if self.default_theme is None: + if not host_name: + # FIXME: we use bulma theme by default for main site for now + # as the development is focusing on this one, and default theme may + # be broken + self.default_theme = 'bulma' + else: + self.default_theme = C.TEMPLATE_THEME_DEFAULT + self.site_themes = set() + self.named_pages = {} + self.browser_modules = {} + # template dynamic data used in all pages + self.dyn_data_common = {"scripts": OrderedSet()} + for theme, data in host.renderer.get_themes_data(site_name).items(): + # we check themes for browser metadata, and merge them here if found + self.site_themes.add(theme) + browser_meta = data.get('browser_meta') + if browser_meta is not None: + log.debug(f"merging browser metadata from theme {theme}: {browser_meta}") + recursive_update(self.browser_modules, browser_meta) + browser_path = data.get('browser_path') + if browser_path is not None: + self.browser_modules.setdefault('themes_browser_paths', set()).add( + browser_path) + try: + next(browser_path.glob("*.py")) + except StopIteration: + pass + else: + log.debug(f"found brython script(s) for theme {theme}") + self.browser_modules.setdefault('brython', []).append( + { + "path": browser_path, + "url_hash": None, + "url_prefix": f"__t_{theme}" + } + ) + + self.uri_callbacks = {} + self.pages_redirects = {} + self.cached_urls = {} + self.main_menu = None + # map Libervia application names => data + self.libervia_apps = {} + self.build_path = host.get_build_path(site_name) + self.build_path.mkdir(parents=True, exist_ok=True) + self.dev_build_path = host.get_build_path(site_name, dev=True) + self.dev_build_path.mkdir(parents=True, exist_ok=True) + self.putChild( + C.BUILD_DIR.encode(), + ProtectedFile( + self.build_path, + defaultType="application/octet-stream"), + ) + + def __str__(self): + return ( + f"Root resource for {self.host_name or 'default host'} using " + f"{self.site_name or 'default site'} at {self.site_path} and deserving " + f"files at {self.path}" + ) + + def config_get(self, key, default=None, value_type=None): + """Retrieve configuration for this site + + params are the same as for [Libervia.config_get] + """ + return self.host.config_get(self, key, default, value_type) + + def get_front_url(self, theme): + return Path( + '/', + C.TPL_RESOURCE, + self.site_name or C.SITE_NAME_DEFAULT, + C.TEMPLATE_TPL_DIR, + theme) + + def add_resource_to_path(self, path: str, resource: web_resource.Resource) -> None: + """Add a resource to the given path + + A "NoResource" will be used for all intermediate segments + """ + segments, __, last_segment = path.rpartition("/") + url_segments = segments.split("/") if segments else [] + current = self + for segment in url_segments: + resource = web_resource.NoResource() + current.putChild(segment, resource) + current = resource + + current.putChild( + last_segment.encode('utf-8'), + resource + ) + + async def _start_app(self, app_name, extra=None) -> dict: + """Start a Libervia App + + @param app_name: canonical application name + @param extra: extra parameter to configure app + @return: app data + app data will not include computed exposed data, at this needs to wait for the + app to be started + """ + if extra is None: + extra = {} + log.info(_( + "starting application {app_name}").format(app_name=app_name)) + app_data = data_format.deserialise( + await self.host.bridge_call( + "application_start", app_name, data_format.serialise(extra) + ) + ) + if app_data.get("started", False): + log.debug(f"application {app_name!r} is already started or starting") + # we do not await on purpose, the workflow should not be blocking at this + # point + defer.ensureDeferred(self._on_app_started(app_name, app_data["instance"])) + else: + self.host.apps_cb[app_data["instance"]] = self._on_app_started + return app_data + + async def _on_app_started( + self, + app_name: str, + instance_id: str + ) -> None: + exposed_data = self.libervia_apps[app_name] = data_format.deserialise( + await self.host.bridge_call("application_exposed_get", app_name, "", "") + ) + + try: + web_port = int(exposed_data['ports']['web'].split(':')[1]) + except (KeyError, ValueError): + log.warning(_( + "no web port found for application {app_name!r}, can't use it " + ).format(app_name=app_name)) + raise exceptions.DataError("no web port found") + + try: + url_prefix = exposed_data['url_prefix'].strip().rstrip('/') + except (KeyError, AttributeError) as e: + log.warning(_( + "no URL prefix specified for this application, we can't embed it: {msg}") + .format(msg=e)) + raise exceptions.DataError("no URL prefix") + + if not url_prefix.startswith('/'): + raise exceptions.DataError( + f"invalid URL prefix, it must start with '/': {url_prefix!r}") + + res = proxy.SatReverseProxyResource( + "localhost", + web_port, + url_prefix.encode() + ) + self.add_resource_to_path(url_prefix, res) + log.info( + f"Resource for app {app_name!r} (instance {instance_id!r}) has been added" + ) + + async def _init_redirections(self, options): + url_redirections = options["url_redirections_dict"] + + url_redirections = url_redirections.get(self.site_name, {}) + + ## redirections + self.redirections = {} + self.inv_redirections = {} # new URL to old URL map + + for old, new_data_list in url_redirections.items(): + # several redirections can be used for one path by using a list. + # The redirection will be done using first item of the list, and all items + # will be used for inverse redirection. + # e.g. if a => [b, c], a will redirect to c, and b and c will both be + # equivalent to a + if not isinstance(new_data_list, list): + new_data_list = [new_data_list] + for new_data in new_data_list: + # new_data can be a dictionary or a unicode url + if isinstance(new_data, dict): + # new_data dict must contain either "url", "page" or "path" key + # (exclusive) + # if "path" is used, a file url is constructed with it + if (( + len( + {"path", "url", "page"}.intersection(list(new_data.keys())) + ) != 1 + )): + raise ValueError( + 'You must have one and only one of "url", "page" or "path" ' + 'key in your url_redirections_dict data' + ) + if "url" in new_data: + new = new_data["url"] + elif "page" in new_data: + new = new_data + new["type"] = "page" + new.setdefault("path_args", []) + if not isinstance(new["path_args"], list): + log.error( + _('"path_args" in redirection of {old} must be a list. ' + 'Ignoring the redirection'.format(old=old))) + continue + new.setdefault("query_args", {}) + if not isinstance(new["query_args"], dict): + log.error( + _( + '"query_args" in redirection of {old} must be a ' + 'dictionary. Ignoring the redirection' + ).format(old=old) + ) + continue + new["path_args"] = [quote(a) for a in new["path_args"]] + # we keep an inversed dict of page redirection + # (page/path_args => redirecting URL) + # so get_url can return the redirecting URL if the same arguments + # are used # making the URL consistent + args_hash = tuple(new["path_args"]) + self.pages_redirects.setdefault(new_data["page"], {}).setdefault( + args_hash, + old + ) + + # we need lists in query_args because it will be used + # as it in request.path_args + for k, v in new["query_args"].items(): + if isinstance(v, str): + new["query_args"][k] = [v] + elif "path" in new_data: + new = "file:{}".format(urllib.parse.quote(new_data["path"])) + elif isinstance(new_data, str): + new = new_data + new_data = {} + else: + log.error( + _("ignoring invalid redirection value: {new_data}").format( + new_data=new_data + ) + ) + continue + + # some normalization + if not old.strip(): + # root URL special case + old = "" + elif not old.startswith("/"): + log.error( + _("redirected url must start with '/', got {value}. Ignoring") + .format(value=old) + ) + continue + else: + old = self._normalize_url(old) + + if isinstance(new, dict): + # dict are handled differently, they contain data + # which ared use dynamically when the request is done + self.redirections.setdefault(old, new) + if not old: + if new["type"] == "page": + log.info( + _("Root URL redirected to page {name}").format( + name=new["page"] + ) + ) + else: + if new["type"] == "page": + page = self.get_page_by_name(new["page"]) + url = page.get_url(*new.get("path_args", [])) + self.inv_redirections[url] = old + continue + + # at this point we have a redirection URL in new, we can parse it + new_url = urllib.parse.urlsplit(new) + + # we handle the known URL schemes + if new_url.scheme == "xmpp": + location = self.get_page_path_from_uri(new) + if location is None: + log.warning( + _("ignoring redirection, no page found to handle this URI: " + "{uri}").format(uri=new)) + continue + request_data = self._get_request_data(location) + self.inv_redirections[location] = old + + elif new_url.scheme in ("", "http", "https"): + # direct redirection + if new_url.netloc: + raise NotImplementedError( + "netloc ({netloc}) is not implemented yet for " + "url_redirections_dict, it is not possible to redirect to an " + "external website".format(netloc=new_url.netloc)) + location = urllib.parse.urlunsplit( + ("", "", new_url.path, new_url.query, new_url.fragment) + ) + request_data = self._get_request_data(location) + self.inv_redirections[location] = old + + elif new_url.scheme == "file": + # file or directory + if new_url.netloc: + raise NotImplementedError( + "netloc ({netloc}) is not implemented for url redirection to " + "file system, it is not possible to redirect to an external " + "host".format( + netloc=new_url.netloc)) + path = urllib.parse.unquote(new_url.path) + if not os.path.isabs(path): + raise ValueError( + "file redirection must have an absolute path: e.g. " + "file:/path/to/my/file") + # for file redirection, we directly put child here + resource_class = ( + ProtectedFile if new_data.get("protected", True) else static.File + ) + res = resource_class(path, defaultType="application/octet-stream") + self.add_resource_to_path(old, res) + log.info("[{host_name}] Added redirection from /{old} to file system " + "path {path}".format(host_name=self.host_name, + old=old, + path=path)) + + # we don't want to use redirection system, so we continue here + continue + + elif new_url.scheme == "libervia-app": + # a Libervia application + + app_name = urllib.parse.unquote(new_url.path).lower().strip() + extra = {"url_prefix": f"/{old}"} + try: + await self._start_app(app_name, extra) + except Exception as e: + log.warning(_( + "Can't launch {app_name!r} for path /{old}: {e}").format( + app_name=app_name, old=old, e=e)) + continue + + log.info( + f"[{self.host_name}] Added redirection from /{old} to " + f"application {app_name}" + ) + # normal redirection system is not used here + continue + elif new_url.scheme == "proxy": + # a reverse proxy + host, port = new_url.hostname, new_url.port + if host is None or port is None: + raise ValueError( + "invalid host or port in proxy redirection, please check your " + "configuration: {new_url.geturl()}" + ) + url_prefix = (new_url.path or old).rstrip('/') + res = proxy.SatReverseProxyResource( + host, + port, + url_prefix.encode(), + ) + self.add_resource_to_path(old, res) + log.info( + f"[{self.host_name}] Added redirection from /{old} to reverse proxy " + f"{new_url.netloc} with URL prefix {url_prefix}/" + ) + + # normal redirection system is not used here + continue + else: + raise NotImplementedError( + "{scheme}: scheme is not managed for url_redirections_dict".format( + scheme=new_url.scheme + ) + ) + + self.redirections.setdefault(old, request_data) + if not old: + log.info(_("[{host_name}] Root URL redirected to {uri}") + .format(host_name=self.host_name, + uri=request_data[1])) + + # the default root URL, if not redirected + if not "" in self.redirections: + self.redirections[""] = self._get_request_data(C.LIBERVIA_PAGE_START) + + async def _set_menu(self, menus): + menus = menus.get(self.site_name, []) + main_menu = [] + for menu in menus: + if not menu: + msg = _("menu item can't be empty") + log.error(msg) + raise ValueError(msg) + elif isinstance(menu, list): + if len(menu) != 2: + msg = _( + "menu item as list must be in the form [page_name, absolue URL]" + ) + log.error(msg) + raise ValueError(msg) + page_name, url = menu + elif menu.startswith("libervia-app:"): + app_name = menu[13:].strip().lower() + app_data = await self._start_app(app_name) + exposed_data = app_data["expose"] + front_url = exposed_data['front_url'] + options = self.host.options + url_redirections = options["url_redirections_dict"].setdefault( + self.site_name, {} + ) + if front_url in url_redirections: + raise exceptions.ConflictError( + f"There is already a redirection from {front_url!r}, can't add " + f"{app_name!r}") + + url_redirections[front_url] = { + "page": 'embed_app', + "path_args": [app_name] + } + + page_name = exposed_data.get('web_label', app_name).title() + url = front_url + + log.debug( + f"Application {app_name} added to menu of {self.site_name}" + ) + else: + page_name = menu + try: + url = self.get_page_by_name(page_name).url + except KeyError as e: + log_msg = _("Can'find a named page ({msg}), please check " + "menu_json in configuration.").format(msg=e.args[0]) + log.error(log_msg) + raise exceptions.ConfigError(log_msg) + main_menu.append((page_name, url)) + self.main_menu = main_menu + + def _normalize_url(self, url, lower=True): + """Return URL normalized for self.redirections dict + + @param url(unicode): URL to normalize + @param lower(bool): lower case of url if True + @return (str): normalized URL + """ + if lower: + url = url.lower() + return "/".join((p for p in url.split("/") if p)) + + def _get_request_data(self, uri): + """Return data needed to redirect request + + @param url(unicode): destination url + @return (tuple(list[str], str, str, dict): tuple with + splitted path as in Request.postpath + uri as in Request.uri + path as in Request.path + args as in Request.args + """ + uri = uri + # XXX: we reuse code from twisted.web.http.py here + # as we need to have the same behaviour + x = uri.split("?", 1) + + if len(x) == 1: + path = uri + args = {} + else: + path, argstring = x + args = urllib.parse.parse_qs(argstring, True) + + # XXX: splitted path case must not be changed, as it may be significant + # (e.g. for blog items) + return ( + self._normalize_url(path, lower=False).split("/"), + uri, + path, + args, + ) + + def _redirect(self, request, request_data): + """Redirect an URL by rewritting request + + this is *NOT* a HTTP redirection, but equivalent to URL rewritting + @param request(web.http.request): original request + @param request_data(tuple): data returned by self._get_request_data + @return (web_resource.Resource): resource to use + """ + # recursion check + try: + request._redirected + except AttributeError: + pass + else: + try: + __, uri, __, __ = request_data + except ValueError: + uri = "" + log.error(D_( "recursive redirection, please fix this URL:\n" + "{old} ==> {new}").format( + old=request.uri.decode("utf-8"), new=uri)) + return web_resource.NoResource() + + request._redirected = True # here to avoid recursive redirections + + if isinstance(request_data, dict): + if request_data["type"] == "page": + try: + page = self.get_page_by_name(request_data["page"]) + except KeyError: + log.error( + _( + 'Can\'t find page named "{name}" requested in redirection' + ).format(name=request_data["page"]) + ) + return web_resource.NoResource() + path_args = [pa.encode('utf-8') for pa in request_data["path_args"]] + request.postpath = path_args + request.postpath + + try: + request.args.update(request_data["query_args"]) + except (TypeError, ValueError): + log.error( + _("Invalid args in redirection: {query_args}").format( + query_args=request_data["query_args"] + ) + ) + return web_resource.NoResource() + return page + else: + raise exceptions.InternalError("unknown request_data type") + else: + path_list, uri, path, args = request_data + path_list = [p.encode('utf-8') for p in path_list] + log.debug( + "Redirecting URL {old} to {new}".format( + old=request.uri.decode('utf-8'), new=uri + ) + ) + # we change the request to reflect the new url + request.postpath = path_list[1:] + request.postpath + request.args.update(args) + + # we start again to look for a child with the new url + return self.getChildWithDefault(path_list[0], request) + + def get_page_by_name(self, name): + """Retrieve page instance from its name + + @param name(unicode): name of the page + @return (LiberviaPage): page instance + @raise KeyError: the page doesn't exist + """ + return self.named_pages[name] + + def get_page_path_from_uri(self, uri): + """Retrieve page URL from xmpp: URI + + @param uri(unicode): URI with a xmpp: scheme + @return (unicode,None): absolute path (starting from root "/") to page handling + the URI. + None is returned if no page has been registered for this URI + """ + uri_data = common_uri.parse_xmpp_uri(uri) + try: + page, cb = self.uri_callbacks[uri_data["type"], uri_data["sub_type"]] + except KeyError: + url = None + else: + url = cb(page, uri_data) + if url is None: + # no handler found + # we try to find a more generic one + try: + page, cb = self.uri_callbacks[uri_data["type"], None] + except KeyError: + pass + else: + url = cb(page, uri_data) + return url + + def getChildWithDefault(self, name, request): + # XXX: this method is overriden only for root url + # which is the only ones who need to be handled before other children + if name == b"" and not request.postpath: + return self._redirect(request, self.redirections[""]) + return super(LiberviaRootResource, self).getChildWithDefault(name, request) + + def getChild(self, name, request): + resource = super(LiberviaRootResource, self).getChild(name, request) + + if isinstance(resource, web_resource.NoResource): + # if nothing was found, we try our luck with redirections + # XXX: we want redirections to happen only if everything else failed + path_elt = request.prepath + request.postpath + for idx in range(len(path_elt), -1, -1): + test_url = b"/".join(path_elt[:idx]).decode('utf-8').lower() + if test_url in self.redirections: + request_data = self.redirections[test_url] + request.postpath = path_elt[idx:] + return self._redirect(request, request_data) + + return resource + + def putChild(self, path, resource): + """Add a child to the root resource""" + if not isinstance(path, bytes): + raise ValueError("path must be specified in bytes") + if not isinstance(resource, web_resource.EncodingResourceWrapper): + # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) + resource = web_resource.EncodingResourceWrapper( + resource, [server.GzipEncoderFactory()]) + + super(LiberviaRootResource, self).putChild(path, resource) + + def createSimilarFile(self, path): + # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource + + f = LiberviaRootResource.__base__( + path, self.defaultType, self.ignoredExts, self.registry + ) + # refactoring by steps, here - constructor should almost certainly take these + f.processors = self.processors + f.indexNames = self.indexNames[:] + f.childNotFound = self.childNotFound + return f
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/restricted_bridge.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 + +# Libervia: a SàT frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from libervia.backend.tools.common import data_format +from libervia.backend.core import exceptions +from libervia.web.server.constants import Const as C + + +class RestrictedBridge: + """bridge with limited access, which can be used in browser + + Only a few method are implemented, with potentially dangerous argument controlled. + Security limit is used + """ + + def __init__(self, host): + self.host = host + self.security_limit = C.SECURITY_LIMIT + + def no_service_profile(self, profile): + """Raise an error if service profile is used""" + if profile == C.SERVICE_PROFILE: + raise exceptions.PermissionError( + "This action is not allowed for service profile" + ) + + async def action_launch( + self, callback_id: str, data_s: str, profile: str + ) -> str: + self.no_service_profile(profile) + return await self.host.bridge_call( + "action_launch", callback_id, data_s, profile + ) + + async def call_start(self, entity: str, call_data_s: str, profile: str) -> None: + self.no_service_profile(profile) + return await self.host.bridge_call( + "call_start", entity, call_data_s, profile + ) + + async def call_end(self, session_id: str, call_data: str, profile: str) -> None: + self.no_service_profile(profile) + return await self.host.bridge_call( + "call_end", session_id, call_data, profile + ) + + async def contacts_get(self, profile): + return await self.host.bridge_call("contacts_get", profile) + + async def external_disco_get(self, entity, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "external_disco_get", entity, profile) + + async def ice_candidates_add(self, session_id, media_ice_data_s, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ice_candidates_add", session_id, media_ice_data_s, profile + ) + + async def identity_get(self, entity, metadata_filter, use_cache, profile): + return await self.host.bridge_call( + "identity_get", entity, metadata_filter, use_cache, profile) + + async def identities_get(self, entities, metadata_filter, profile): + return await self.host.bridge_call( + "identities_get", entities, metadata_filter, profile) + + async def identities_base_get(self, profile): + return await self.host.bridge_call( + "identities_base_get", profile) + + async def ps_node_delete(self, service_s, node, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_node_delete", service_s, node, profile) + + async def ps_node_affiliations_set(self, service_s, node, affiliations, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_node_affiliations_set", service_s, node, affiliations, profile) + + async def ps_item_retract(self, service_s, node, item_id, notify, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_item_retract", service_s, node, item_id, notify, profile) + + async def mb_preview(self, service_s, node, data, profile): + return await self.host.bridge_call( + "mb_preview", service_s, node, data, profile) + + async def list_set(self, service_s, node, values, schema, item_id, extra, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "list_set", service_s, node, values, "", item_id, "", profile) + + + async def file_http_upload_get_slot( + self, filename, size, content_type, upload_jid, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "file_http_upload_get_slot", filename, size, content_type, + upload_jid, profile) + + async def file_sharing_delete( + self, service_jid, path, namespace, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "file_sharing_delete", service_jid, path, namespace, profile) + + async def interests_file_sharing_register( + self, service, repos_type, namespace, path, name, extra_s, profile + ): + self.no_service_profile(profile) + if extra_s: + # we only allow "thumb_url" here + extra = data_format.deserialise(extra_s) + if "thumb_url" in extra: + extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]}) + else: + extra_s = "" + + return await self.host.bridge_call( + "interests_file_sharing_register", service, repos_type, namespace, path, name, + extra_s, profile + ) + + async def interest_retract( + self, service_jid, item_id, profile + ): + self.no_service_profile(profile) + return await self.host.bridge_call( + "interest_retract", service_jid, item_id, profile) + + async def ps_invite( + self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile + ): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile + ) + + async def fis_invite( + self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s, + profile + ): + self.no_service_profile(profile) + if extra_s: + # we only allow "thumb_url" here + extra = data_format.deserialise(extra_s) + if "thumb_url" in extra: + extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]}) + else: + extra_s = "" + + return await self.host.bridge_call( + "fis_invite", invitee_jid_s, service_s, repos_type, namespace, path, name, + extra_s, profile + ) + + async def fis_affiliations_set( + self, service_s, namespace, path, affiliations, profile + ): + self.no_service_profile(profile) + return await self.host.bridge_call( + "fis_affiliations_set", service_s, namespace, path, affiliations, profile + ) + + async def invitation_simple_create( + self, invitee_email, invitee_name, url_template, extra_s, profile + ): + self.no_service_profile(profile) + return await self.host.bridge_call( + "invitation_simple_create", invitee_email, invitee_name, url_template, extra_s, + profile + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/server.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,1374 @@ +#!/usr/bin/env python3 + +# Libervia Web +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from functools import partial +import os.path +from pathlib import Path +import re +import sys +import time +from typing import Callable, Dict, Optional +import urllib.error +import urllib.parse +import urllib.request + +from twisted.application import service +from twisted.internet import defer, inotify, reactor +from twisted.python import failure +from twisted.python import filepath +from twisted.python.components import registerAdapter +from twisted.web import server +from twisted.web import static +from twisted.web import resource as web_resource +from twisted.web import util as web_util +from twisted.web import vhost +from twisted.words.protocols.jabber import jid + +import libervia.web +from libervia.web.server import websockets +from libervia.web.server import session_iface +from libervia.web.server.constants import Const as C +from libervia.web.server.pages import LiberviaPage +from libervia.web.server.tasks.manager import TasksManager +from libervia.web.server.utils import ProgressHandler +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools import utils +from libervia.backend.tools import config +from libervia.backend.tools.common import regex +from libervia.backend.tools.common import template +from libervia.backend.tools.common import data_format +from libervia.backend.tools.common import tls +from libervia.frontends.bridge.bridge_frontend import BridgeException +from libervia.frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge +from libervia.frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT + +from .resources import LiberviaRootResource, ProtectedFile +from .restricted_bridge import RestrictedBridge + +log = getLogger(__name__) + + +DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF + | inotify.IN_MOVED_TO) + + +class SysExit(Exception): + + def __init__(self, exit_code, message=""): + self.exit_code = exit_code + self.message = message + + def __str__(self): + return f"System Exit({self.exit_code}): {self.message}" + + +class FilesWatcher(object): + """Class to check files modifications using iNotify""" + _notifier = None + + def __init__(self, host): + self.host = host + + @property + def notifier(self): + if self._notifier == None: + notifier = self.__class__._notifier = inotify.INotify() + notifier.startReading() + return self._notifier + + def _check_callback(self, dir_path, callback, recursive): + # Twisted doesn't add callback if a watcher was already set on a path + # but in dev mode Libervia watches whole sites + internal path can be watched + # by tasks, so several callbacks must be called on some paths. + # This method check that the new callback is indeed present in the desired path + # and add it otherwise. + # FIXME: this should probably be fixed upstream + if recursive: + for child in dir_path.walk(): + if child.isdir(): + self._check_callback(child, callback, recursive=False) + else: + watch_id = self.notifier._isWatched(dir_path) + if watch_id is None: + log.warning( + f"There is no watch ID for path {dir_path}, this should not happen" + ) + else: + watch_point = self.notifier._watchpoints[watch_id] + if callback not in watch_point.callbacks: + watch_point.callbacks.append(callback) + + def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False, + recursive=False, **kwargs): + dir_path = str(dir_path) + log.info(_("Watching directory {dir_path}").format(dir_path=dir_path)) + wrapped_callback = lambda __, filepath, mask: callback( + self.host, filepath, inotify.humanReadableMask(mask), **kwargs) + callbacks = [wrapped_callback] + dir_path = filepath.FilePath(dir_path) + self.notifier.watch( + dir_path, mask=mask, autoAdd=auto_add, recursive=recursive, + callbacks=callbacks) + self._check_callback(dir_path, wrapped_callback, recursive) + + +class WebSession(server.Session): + sessionTimeout = C.SESSION_TIMEOUT + + def __init__(self, *args, **kwargs): + self.__lock = False + server.Session.__init__(self, *args, **kwargs) + + def lock(self): + """Prevent session from expiring""" + self.__lock = True + self._expireCall.reset(sys.maxsize) + + def unlock(self): + """Allow session to expire again, and touch it""" + self.__lock = False + self.touch() + + def touch(self): + if not self.__lock: + server.Session.touch(self) + + +class WaitingRequests(dict): + def set_request(self, request, profile, register_with_ext_jid=False): + """Add the given profile to the waiting list. + + @param request (server.Request): the connection request + @param profile (str): %(doc_profile)s + @param register_with_ext_jid (bool): True if we will try to register the + profile with an external XMPP account credentials + """ + dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile) + self[profile] = (request, dc, register_with_ext_jid) + + def purge_request(self, profile): + """Remove the given profile from the waiting list. + + @param profile (str): %(doc_profile)s + """ + try: + dc = self[profile][1] + except KeyError: + return + if dc.active(): + dc.cancel() + del self[profile] + + def get_request(self, profile): + """Get the waiting request for the given profile. + + @param profile (str): %(doc_profile)s + @return: the waiting request or None + """ + return self[profile][0] if profile in self else None + + def get_register_with_ext_jid(self, profile): + """Get the value of the register_with_ext_jid parameter. + + @param profile (str): %(doc_profile)s + @return: bool or None + """ + return self[profile][2] if profile in self else None + + +class LiberviaWeb(service.Service): + debug = defer.Deferred.debug # True if twistd/Libervia is launched in debug mode + + def __init__(self, options): + self.options = options + websockets.host = self + + def _init(self): + # we do init here and not in __init__ to avoid doule initialisation with twistd + # this _init is called in startService + self.initialised = defer.Deferred() + self.waiting_profiles = WaitingRequests() # FIXME: should be removed + self._main_conf = None + self.files_watcher = FilesWatcher(self) + + if self.options["base_url_ext"]: + self.base_url_ext = self.options.pop("base_url_ext") + if self.base_url_ext[-1] != "/": + self.base_url_ext += "/" + self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext) + else: + self.base_url_ext = None + # we split empty string anyway so we can do things like + # scheme = self.base_url_ext_data.scheme or 'https' + self.base_url_ext_data = urllib.parse.urlsplit("") + + if not self.options["port_https_ext"]: + self.options["port_https_ext"] = self.options["port_https"] + + self._cleanup = [] + + self.sessions = {} # key = session value = user + self.prof_connected = set() # Profiles connected + self.ns_map = {} # map of short name to namespaces + + ## bridge ## + self._bridge_retry = self.options['bridge-retries'] + self.bridge = bridge() + self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) + + ## libervia app callbacks ## + # mapping instance id to the callback to call on "started" signal + self.apps_cb: Dict[str, Callable] = {} + + @property + def roots(self): + """Return available virtual host roots + + Root resources are only returned once, even if they are present for multiple + named vhosts. Order is not relevant, except for default vhost which is always + returned first. + @return (list[web_resource.Resource]): all vhost root resources + """ + roots = list(set(self.vhost_root.hosts.values())) + default = self.vhost_root.default + if default is not None and default not in roots: + roots.insert(0, default) + return roots + + @property + def main_conf(self): + """SafeConfigParser instance opened on configuration file (libervia.conf)""" + if self._main_conf is None: + self._main_conf = config.parse_main_conf(log_filenames=True) + return self._main_conf + + def config_get(self, site_root_res, key, default=None, value_type=None): + """Retrieve configuration associated to a site + + Section is automatically set to site name + @param site_root_res(LiberviaRootResource): resource of the site in use + @param key(unicode): key to use + @param default: value to use if not found (see [config.config_get]) + @param value_type(unicode, None): filter to use on value + Note that filters are already automatically used when the key finish + by a well known suffix ("_path", "_list", "_dict", or "_json") + None to use no filter, else can be: + - "path": a path is expected, will be normalized and expanded + + """ + section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION + value = config.config_get(self.main_conf, section, key, default=default) + if value_type is not None: + if value_type == 'path': + v_filter = lambda v: os.path.abspath(os.path.expanduser(v)) + else: + raise ValueError("unknown value type {value_type}".format( + value_type = value_type)) + if isinstance(value, list): + value = [v_filter(v) for v in value] + elif isinstance(value, dict): + value = {k:v_filter(v) for k,v in list(value.items())} + elif value is not None: + value = v_filter(value) + return value + + def _namespaces_get_cb(self, ns_map): + self.ns_map = {str(k): str(v) for k,v in ns_map.items()} + + def _namespaces_get_eb(self, failure_): + log.error(_("Can't get namespaces map: {msg}").format(msg=failure_)) + + @template.contextfilter + def _front_url_filter(self, ctx, relative_url): + template_data = ctx['template_data'] + return os.path.join( + '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT, + C.TEMPLATE_TPL_DIR, template_data.theme, relative_url) + + def _move_first_level_to_dict(self, options, key, keys_to_keep): + """Read a config option and put value at first level into u'' dict + + This is useful to put values for Libervia official site directly in dictionary, + and to use site_name as keys when external sites are used. + options will be modified in place + @param options(dict): options to modify + @param key(unicode): setting key to modify + @param keys_to_keep(list(unicode)): keys allowed in first level + """ + try: + conf = options[key] + except KeyError: + return + if not isinstance(conf, dict): + options[key] = {'': conf} + return + default_dict = conf.get('', {}) + to_delete = [] + for key, value in conf.items(): + if key not in keys_to_keep: + default_dict[key] = value + to_delete.append(key) + for key in to_delete: + del conf[key] + if default_dict: + conf[''] = default_dict + + async def check_and_connect_service_profile(self): + passphrase = self.options["passphrase"] + if not passphrase: + raise SysExit( + C.EXIT_BAD_ARG, + _("No passphrase set for service profile, please check installation " + "documentation.") + ) + try: + s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE) + except BridgeException as e: + if e.classname == "ProfileUnknownError": + log.info("Service profile doesn't exist, creating it.") + try: + xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain") + xmpp_domain = xmpp_domain.strip() + if not xmpp_domain: + raise SysExit( + C.EXIT_BAD_ARG, + _('"xmpp_domain" must be set to create new accounts, please ' + 'check documentation') + ) + service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}" + await self.bridge_call( + "in_band_account_new", + service_profile_jid_s, + passphrase, + "", + xmpp_domain, + 0, + ) + except BridgeException as e: + if e.condition == "conflict": + log.info( + _("Service's profile JID {profile_jid} already exists") + .format(profile_jid=service_profile_jid_s) + ) + elif e.classname == "UnknownMethod": + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Can't create service profile XMPP account, In-Band " + "Registration plugin is not activated, you'll have to " + "create the {profile!r} profile with {profile_jid!r} JID " + "manually.").format( + profile=C.SERVICE_PROFILE, + profile_jid=service_profile_jid_s) + ) + elif e.condition == "service-unavailable": + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Can't create service profile XMPP account, In-Band " + "Registration is not activated on your server, you'll have " + "to create the {profile!r} profile with {profile_jid!r} JID " + "manually.\nNote that you'll need to activate In-Band " + "Registation on your server if you want users to be able " + "to create new account from {app_name}, please check " + "documentation.").format( + profile=C.SERVICE_PROFILE, + profile_jid=service_profile_jid_s, + app_name=C.APP_NAME) + ) + elif e.condition == "not-acceptable": + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Can't create service profile XMPP account, your XMPP " + "server doesn't allow us to create new accounts with " + "In-Band Registration please check XMPP server " + "configuration: {reason}" + ).format( + profile=C.SERVICE_PROFILE, + profile_jid=service_profile_jid_s, + reason=e.message) + ) + + else: + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Can't create service profile XMPP account, you'll have " + "do to it manually: {reason}").format(reason=e.message) + ) + try: + await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase) + await self.bridge_call( + "profile_start_session", passphrase, C.SERVICE_PROFILE) + await self.bridge_call( + "param_set", "JabberID", service_profile_jid_s, "Connection", -1, + C.SERVICE_PROFILE) + await self.bridge_call( + "param_set", "Password", passphrase, "Connection", -1, + C.SERVICE_PROFILE) + except BridgeException as e: + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Can't create service profile XMPP account, you'll have " + "do to it manually: {reason}").format(reason=e.message) + ) + log.info(_("Service profile has been successfully created")) + s_prof_connected = False + else: + raise SysExit(C.EXIT_BRIDGE_ERROR, e.message) + + if not s_prof_connected: + try: + await self.bridge_call( + "connect", + C.SERVICE_PROFILE, + passphrase, + {}, + ) + except BridgeException as e: + raise SysExit( + C.EXIT_BRIDGE_ERROR, + _("Connection of service profile failed: {reason}").format(reason=e) + ) + + async def backend_ready(self): + log.info(f"Libervia Web v{self.full_version}") + + # settings + if self.options['dev-mode']: + log.info(_("Developer mode activated")) + self.media_dir = await self.bridge_call("config_get", "", "media_dir") + self.local_dir = await self.bridge_call("config_get", "", "local_dir") + self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR) + self.renderer = template.Renderer(self, self._front_url_filter) + sites_names = list(self.renderer.sites_paths.keys()) + + self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names) + self._move_first_level_to_dict(self.options, "menu_json", sites_names) + self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names) + menu = self.options["menu_json"] + if not '' in menu: + menu[''] = C.DEFAULT_MENU + for site, value in self.options["menu_extra_json"].items(): + menu[site].extend(value) + + # service profile + if not self.options['build-only']: + await self.check_and_connect_service_profile() + + # restricted bridge, the one used by browser code + self.restricted_bridge = RestrictedBridge(self) + + # we create virtual hosts and import Libervia pages into them + self.vhost_root = vhost.NameVirtualHost() + default_site_path = Path(libervia.web.__file__).parent.resolve() + # self.sat_root is official Libervia site + root_path = default_site_path / C.TEMPLATE_STATIC_DIR + self.sat_root = default_root = LiberviaRootResource( + host=self, host_name='', site_name='', + site_path=default_site_path, path=root_path) + if self.options['dev-mode']: + self.files_watcher.watch_dir( + default_site_path, auto_add=True, recursive=True, + callback=LiberviaPage.on_file_change, site_root=self.sat_root, + site_path=default_site_path) + LiberviaPage.import_pages(self, self.sat_root) + tasks_manager = TasksManager(self, self.sat_root) + await tasks_manager.parse_tasks() + await tasks_manager.run_tasks() + # FIXME: handle _set_menu in a more generic way, taking care of external sites + await self.sat_root._set_menu(self.options["menu_json"]) + self.vhost_root.default = default_root + existing_vhosts = {b'': default_root} + + for host_name, site_name in self.options["vhosts_dict"].items(): + if site_name == C.SITE_NAME_DEFAULT: + raise ValueError( + f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict") + encoded_site_name = site_name.encode('utf-8') + try: + site_path = self.renderer.sites_paths[site_name] + except KeyError: + log.warning(_( + "host {host_name} link to non existing site {site_name}, ignoring " + "it").format(host_name=host_name, site_name=site_name)) + continue + if encoded_site_name in existing_vhosts: + # we have an alias host, we re-use existing resource + res = existing_vhosts[encoded_site_name] + else: + # for root path we first check if there is a global static dir + # if not, we use default template's static dir + root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR) + if not os.path.isdir(root_path): + root_path = os.path.join( + site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT, + C.TEMPLATE_STATIC_DIR) + res = LiberviaRootResource( + host=self, + host_name=host_name, + site_name=site_name, + site_path=site_path, + path=root_path) + + existing_vhosts[encoded_site_name] = res + + if self.options['dev-mode']: + self.files_watcher.watch_dir( + site_path, auto_add=True, recursive=True, + callback=LiberviaPage.on_file_change, site_root=res, + # FIXME: site_path should always be a Path, check code above and + # in template module + site_path=Path(site_path)) + + LiberviaPage.import_pages(self, res) + # FIXME: default pages are accessible if not overriden by external website + # while necessary for login or re-using existing pages + # we may want to disable access to the page by direct URL + # (e.g. /blog disabled except if called by external site) + LiberviaPage.import_pages(self, res, root_path=default_site_path) + tasks_manager = TasksManager(self, res) + await tasks_manager.parse_tasks() + await tasks_manager.run_tasks() + await res._set_menu(self.options["menu_json"]) + + self.vhost_root.addHost(host_name.encode('utf-8'), res) + + templates_res = web_resource.Resource() + self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res) + for site_name, site_path in self.renderer.sites_paths.items(): + templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(), + static.File(site_path)) + + d = self.bridge_call("namespaces_get") + d.addCallback(self._namespaces_get_cb) + d.addErrback(self._namespaces_get_eb) + + # websocket + if self.options["connection_type"] in ("https", "both"): + wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True) + self.put_child_all(b'wss', wss) + if self.options["connection_type"] in ("http", "both"): + ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False) + self.put_child_all(b'ws', ws) + + # following signal is needed for cache handling in Libervia pages + self.bridge.register_signal( + "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin" + ) + self.bridge.register_signal( + "message_new", partial(self.on_signal, "message_new") + ) + self.bridge.register_signal( + "call_accepted", partial(self.on_signal, "call_accepted"), "plugin" + ) + self.bridge.register_signal( + "call_ended", partial(self.on_signal, "call_ended"), "plugin" + ) + self.bridge.register_signal( + "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin" + ) + self.bridge.register_signal( + "action_new", self.action_new_handler, + ) + + # libervia applications handling + self.bridge.register_signal( + "application_started", self.application_started_handler, "plugin" + ) + self.bridge.register_signal( + "application_error", self.application_error_handler, "plugin" + ) + + # Progress handling + self.bridge.register_signal( + "progress_started", partial(ProgressHandler._signal, "started") + ) + self.bridge.register_signal( + "progress_finished", partial(ProgressHandler._signal, "finished") + ) + self.bridge.register_signal( + "progress_error", partial(ProgressHandler._signal, "error") + ) + + # media dirs + # FIXME: get rid of dirname and "/" in C.XXX_DIR + self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'), + ProtectedFile(self.media_dir)) + + self.cache_resource = web_resource.NoResource() + self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource) + self.cache_resource.putChild( + b"common", ProtectedFile(str(self.cache_root_dir / Path("common")))) + + # redirections + for root in self.roots: + await root._init_redirections(self.options) + + # no need to keep url_redirections_dict, it will not be used anymore + del self.options["url_redirections_dict"] + + server.Request.defaultContentType = "text/html; charset=utf-8" + wrapped = web_resource.EncodingResourceWrapper( + self.vhost_root, [server.GzipEncoderFactory()] + ) + self.site = server.Site(wrapped) + self.site.sessionFactory = WebSession + + def _bridge_cb(self): + del self._bridge_retry + self.bridge.ready_get( + lambda: self.initialised.callback(None), + lambda failure: self.initialised.errback(Exception(failure)), + ) + self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready())) + + def _bridge_eb(self, failure_): + if isinstance(failure_, BridgeExceptionNoService): + if self._bridge_retry: + if self._bridge_retry < 0: + print(_("Can't connect to bridge, will retry indefinitely. " + "Next try in 1s.")) + else: + self._bridge_retry -= 1 + print( + _( + "Can't connect to bridge, will retry in 1 s ({retries_left} " + "trie(s) left)." + ).format(retries_left=self._bridge_retry) + ) + time.sleep(1) + self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) + return + + print("Can't connect to SàT backend, are you sure it's launched ?") + else: + log.error("Can't connect to bridge: {}".format(failure)) + sys.exit(1) + + @property + def version(self): + """Return the short version of Libervia""" + return C.APP_VERSION + + @property + def full_version(self): + """Return the full version of Libervia (with extra data when in dev mode)""" + version = self.version + if version[-1] == "D": + # we are in debug version, we add extra data + try: + return self._version_cache + except AttributeError: + self._version_cache = "{} ({})".format( + version, utils.get_repository_data(libervia.web) + ) + return self._version_cache + else: + return version + + def bridge_call(self, method_name, *args, **kwargs): + """Call an asynchronous bridge method and return a deferred + + @param method_name: name of the method as a unicode + @return: a deferred which trigger the result + + """ + d = defer.Deferred() + + def _callback(*args): + if not args: + d.callback(None) + else: + if len(args) != 1: + Exception("Multiple return arguments not supported") + d.callback(args[0]) + + def _errback(failure_): + d.errback(failure.Failure(failure_)) + + kwargs["callback"] = _callback + kwargs["errback"] = _errback + getattr(self.bridge, method_name)(*args, **kwargs) + return d + + def action_new_handler( + self, + action_data_s: str, + action_id: str, + security_limit: int, + profile: str + ) -> None: + if security_limit > C.SECURITY_LIMIT: + log.debug( + f"ignoring action {action_id} due to security limit" + ) + else: + self.on_signal( + "action_new", action_data_s, action_id, security_limit, profile + ) + + def on_signal(self, signal_name, *args): + profile = args[-1] + if not profile: + log.error(f"got signal without profile: {signal_name}, {args}") + return + session_iface.WebSession.send( + profile, + "bridge", + {"signal": signal_name, "args": args} + ) + + def application_started_handler( + self, + name: str, + instance_id: str, + extra_s: str + ) -> None: + callback = self.apps_cb.pop(instance_id, None) + if callback is not None: + defer.ensureDeferred(callback(str(name), str(instance_id))) + + def application_error_handler( + self, + name: str, + instance_id: str, + extra_s: str + ) -> None: + callback = self.apps_cb.pop(instance_id, None) + if callback is not None: + extra = data_format.deserialise(extra_s) + log.error( + f"Can't start application {name}: {extra['class']}\n{extra['msg']}" + ) + + async def _logged(self, profile, request): + """Set everything when a user just logged in + + @param profile + @param request + @return: a constant indicating the state: + - C.PROFILE_LOGGED + - C.PROFILE_LOGGED_EXT_JID + @raise exceptions.ConflictError: session is already active + """ + register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile) + self.waiting_profiles.purge_request(profile) + session = request.getSession() + web_session = session_iface.IWebSession(session) + if web_session.profile: + log.error(_("/!\\ Session has already a profile, this should NEVER happen!")) + raise failure.Failure(exceptions.ConflictError("Already active")) + + # XXX: we force string because python D-Bus has its own string type (dbus.String) + # which may cause trouble when exposing it to scripts + web_session.profile = str(profile) + self.prof_connected.add(profile) + cache_dir = os.path.join( + self.cache_root_dir, "profiles", regex.path_escape(profile) + ) + # FIXME: would be better to have a global /cache URL which redirect to + # profile's cache directory, without uuid + self.cache_resource.putChild(web_session.uuid.encode('utf-8'), + ProtectedFile(cache_dir)) + log.debug( + _("profile cache resource added from {uuid} to {path}").format( + uuid=web_session.uuid, path=cache_dir + ) + ) + + def on_expire(): + log.info("Session expired (profile={profile})".format(profile=profile)) + self.cache_resource.delEntity(web_session.uuid.encode('utf-8')) + log.debug( + _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid) + ) + web_session.on_expire() + if web_session.ws_socket is not None: + web_session.ws_socket.close() + # and now we disconnect the profile + self.bridge_call("disconnect", profile) + + session.notifyOnExpire(on_expire) + + # FIXME: those session infos should be returned by connect or is_connected + infos = await self.bridge_call("session_infos_get", profile) + web_session.jid = jid.JID(infos["jid"]) + own_bare_jid_s = web_session.jid.userhost() + own_id_raw = await self.bridge_call( + "identity_get", own_bare_jid_s, [], True, profile) + web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw) + web_session.backend_started = int(infos["started"]) + + state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED + return state + + @defer.inlineCallbacks + def connect(self, request, login, password): + """log user in + + If an other user was already logged, it will be unlogged first + @param request(server.Request): request linked to the session + @param login(unicode): user login + can be profile name + can be profile@[libervia_domain.ext] + can be a jid (a new profile will be created with this jid if needed) + @param password(unicode): user password + @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else + self._logged value + @raise exceptions.DataError: invalid login + @raise exceptions.ProfileUnknownError: this login doesn't exist + @raise exceptions.PermissionError: a login is not accepted (e.g. empty password + not allowed) + @raise exceptions.NotReady: a profile connection is already waiting + @raise exceptions.TimeoutError: didn't received and answer from bridge + @raise exceptions.InternalError: unknown error + @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password + @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password + """ + + # XXX: all security checks must be done here, even if present in javascript + if login.startswith("@"): + raise failure.Failure(exceptions.DataError("No profile_key allowed")) + + if login.startswith("guest@@") and login.count("@") == 2: + log.debug("logging a guest account") + elif "@" in login: + if login.count("@") != 1: + raise failure.Failure( + exceptions.DataError("Invalid login: {login}".format(login=login)) + ) + try: + login_jid = jid.JID(login) + except (RuntimeError, jid.InvalidFormat, AttributeError): + raise failure.Failure(exceptions.DataError("No profile_key allowed")) + + # FIXME: should it be cached? + new_account_domain = yield self.bridge_call("account_domain_new_get") + + if login_jid.host == new_account_domain: + # redirect "user@libervia.org" to the "user" profile + login = login_jid.user + login_jid = None + else: + login_jid = None + + try: + profile = yield self.bridge_call("profile_name_get", login) + except Exception: # XXX: ProfileUnknownError wouldn't work, it's encapsulated + # FIXME: find a better way to handle bridge errors + if ( + login_jid is not None and login_jid.user + ): # try to create a new libervia.backend profile using the XMPP credentials + if not self.options["allow_registration"]: + log.warning( + "Trying to register JID account while registration is not " + "allowed") + raise failure.Failure( + exceptions.DataError( + "JID login while registration is not allowed" + ) + ) + profile = login # FIXME: what if there is a resource? + connect_method = "credentials_xmpp_connect" + register_with_ext_jid = True + else: # non existing username + raise failure.Failure(exceptions.ProfileUnknownError()) + else: + if profile != login or ( + not password + and profile + not in self.options["empty_password_allowed_warning_dangerous_list"] + ): + # profiles with empty passwords are restricted to local frontends + raise exceptions.PermissionError + register_with_ext_jid = False + + connect_method = "connect" + + # we check if there is not already an active session + web_session = session_iface.IWebSession(request.getSession()) + if web_session.profile: + # yes, there is + if web_session.profile != profile: + # it's a different profile, we need to disconnect it + log.warning(_( + "{new_profile} requested login, but {old_profile} was already " + "connected, disconnecting {old_profile}").format( + old_profile=web_session.profile, new_profile=profile)) + self.purge_session(request) + + if self.waiting_profiles.get_request(profile): + # FIXME: check if and when this can happen + raise failure.Failure(exceptions.NotReady("Already waiting")) + + self.waiting_profiles.set_request(request, profile, register_with_ext_jid) + try: + connected = yield self.bridge_call(connect_method, profile, password) + except Exception as failure_: + fault = getattr(failure_, 'classname', None) + self.waiting_profiles.purge_request(profile) + if fault in ("PasswordError", "ProfileUnknownError"): + log.info("Profile {profile} doesn't exist or the submitted password is " + "wrong".format( profile=profile)) + raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR)) + elif fault == "SASLAuthError": + log.info("The XMPP password of profile {profile} is wrong" + .format(profile=profile)) + raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR)) + elif fault == "NoReply": + log.info(_("Did not receive a reply (the timeout expired or the " + "connection is broken)")) + raise exceptions.TimeOutError + elif fault is None: + log.info(_("Unexepected failure: {failure_}").format(failure_=failure)) + raise failure_ + else: + log.error('Unmanaged fault class "{fault}" in errback for the ' + 'connection of profile {profile}'.format( + fault=fault, profile=profile)) + raise failure.Failure(exceptions.InternalError(fault)) + + if connected: + # profile is already connected in backend + # do we have a corresponding session in Libervia? + web_session = session_iface.IWebSession(request.getSession()) + if web_session.profile: + # yes, session is active + if web_session.profile != profile: + # existing session should have been ended above + # so this line should never be reached + log.error(_( + "session profile [{session_profile}] differs from login " + "profile [{profile}], this should not happen!") + .format(session_profile=web_session.profile, profile=profile)) + raise exceptions.InternalError("profile mismatch") + defer.returnValue(C.SESSION_ACTIVE) + log.info( + _( + "profile {profile} was already connected in backend".format( + profile=profile + ) + ) + ) + # no, we have to create it + + state = yield defer.ensureDeferred(self._logged(profile, request)) + defer.returnValue(state) + + def register_new_account(self, request, login, password, email): + """Create a new account, or return error + @param request(server.Request): request linked to the session + @param login(unicode): new account requested login + @param email(unicode): new account email + @param password(unicode): new account password + @return(unicode): a constant indicating the state: + - C.BAD_REQUEST: something is wrong in the request (bad arguments) + - C.INVALID_INPUT: one of the data is not valid + - C.REGISTRATION_SUCCEED: new account has been successfully registered + - C.ALREADY_EXISTS: the given profile already exists + - C.INTERNAL_ERROR or any unmanaged fault string + @raise PermissionError: registration is now allowed in server configuration + """ + if not self.options["allow_registration"]: + log.warning( + _("Registration received while it is not allowed, hack attempt?") + ) + raise failure.Failure( + exceptions.PermissionError("Registration is not allowed on this server") + ) + + if ( + not re.match(C.REG_LOGIN_RE, login) + or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE) + or len(password) < C.PASSWORD_MIN_LENGTH + ): + return C.INVALID_INPUT + + def registered(result): + return C.REGISTRATION_SUCCEED + + def registering_error(failure_): + # FIXME: better error handling for bridge error is needed + status = failure_.value.fullname.split('.')[-1] + if status == "ConflictError": + return C.ALREADY_EXISTS + elif status == "InvalidCertificate": + return C.INVALID_CERTIFICATE + elif status == "InternalError": + return C.INTERNAL_ERROR + else: + log.error( + _("Unknown registering error status: {status}\n{traceback}").format( + status=status, traceback=failure_.value.message + ) + ) + return status + + d = self.bridge_call("libervia_account_register", email, password, login) + d.addCallback(registered) + d.addErrback(registering_error) + return d + + def addCleanup(self, callback, *args, **kwargs): + """Add cleaning method to call when service is stopped + + cleaning method will be called in reverse order of they insertion + @param callback: callable to call on service stop + @param *args: list of arguments of the callback + @param **kwargs: list of keyword arguments of the callback""" + self._cleanup.insert(0, (callback, args, kwargs)) + + def init_eb(self, failure): + from twisted.application import app + if failure.check(SysExit): + if failure.value.message: + log.error(failure.value.message) + app._exitCode = failure.value.exit_code + reactor.stop() + else: + log.error(_("Init error: {msg}").format(msg=failure)) + app._exitCode = C.EXIT_INTERNAL_ERROR + reactor.stop() + return failure + + def _build_only_cb(self, __): + log.info(_("Stopping here due to --build-only flag")) + self.stop() + + def startService(self): + """Connect the profile for Libervia and start the HTTP(S) server(s)""" + self._init() + if self.options['build-only']: + self.initialised.addCallback(self._build_only_cb) + else: + self.initialised.addCallback(self._start_service) + self.initialised.addErrback(self.init_eb) + + ## URLs ## + + def put_child_sat(self, path, resource): + """Add a child to the libervia.backend resource""" + if not isinstance(path, bytes): + raise ValueError("path must be specified in bytes") + self.sat_root.putChild(path, resource) + + def put_child_all(self, path, resource): + """Add a child to all vhost root resources""" + if not isinstance(path, bytes): + raise ValueError("path must be specified in bytes") + # we wrap before calling putChild, to avoid having useless multiple instances + # of the resource + # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) + wrapped_res = web_resource.EncodingResourceWrapper( + resource, [server.GzipEncoderFactory()]) + for root in self.roots: + root.putChild(path, wrapped_res) + + def get_build_path(self, site_name: str, dev: bool=False) -> Path: + """Generate build path for a given site name + + @param site_name: name of the site + @param dev: return dev build dir if True, production one otherwise + dev build dir is used for installing dependencies needed temporarily (e.g. + to compile files), while production build path is the one served by the + HTTP server, where final files are downloaded. + @return: path to the build directory + """ + sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR + build_path_elts = [ + config.config_get(self.main_conf, "", "local_dir"), + C.CACHE_DIR, + C.LIBERVIA_CACHE, + sub_dir, + regex.path_escape(site_name or C.SITE_NAME_DEFAULT)] + build_path = Path("/".join(build_path_elts)) + return build_path.expanduser().resolve() + + def get_ext_base_url_data(self, request): + """Retrieve external base URL Data + + this method try to retrieve the base URL found by external user + It does by checking in this order: + - base_url_ext option from configuration + - proxy x-forwarder-host headers + - URL of the request + @return (urlparse.SplitResult): SplitResult instance with only scheme and + netloc filled + """ + ext_data = self.base_url_ext_data + url_path = request.URLPath() + + try: + forwarded = request.requestHeaders.getRawHeaders( + "forwarded" + )[0] + except TypeError: + # we try deprecated headers + try: + proxy_netloc = request.requestHeaders.getRawHeaders( + "x-forwarded-host" + )[0] + except TypeError: + proxy_netloc = None + try: + proxy_scheme = request.requestHeaders.getRawHeaders( + "x-forwarded-proto" + )[0] + except TypeError: + proxy_scheme = None + else: + fwd_data = { + k.strip(): v.strip() + for k,v in (d.split("=") for d in forwarded.split(";")) + } + proxy_netloc = fwd_data.get("host") + proxy_scheme = fwd_data.get("proto") + + return urllib.parse.SplitResult( + ext_data.scheme or proxy_scheme or url_path.scheme.decode(), + ext_data.netloc or proxy_netloc or url_path.netloc.decode(), + ext_data.path or "/", + "", + "", + ) + + def get_ext_base_url( + self, + request: server.Request, + path: str = "", + query: str = "", + fragment: str = "", + scheme: Optional[str] = None, + ) -> str: + """Get external URL according to given elements + + external URL is the URL seen by external user + @param path: same as for urlsplit.urlsplit + path will be prefixed to follow found external URL if suitable + @param params: same as for urlsplit.urlsplit + @param query: same as for urlsplit.urlsplit + @param fragment: same as for urlsplit.urlsplit + @param scheme: if not None, will override scheme from base URL + @return: external URL + """ + split_result = self.get_ext_base_url_data(request) + return urllib.parse.urlunsplit( + ( + split_result.scheme if scheme is None else scheme, + split_result.netloc, + os.path.join(split_result.path, path), + query, + fragment, + ) + ) + + def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str: + """check is a part of the URL prefix is redirected then replace it + + @param vhost_root: root of this virtual host + @param url_path: path of the url to check + @return: possibly redirected URL which should link to the same location + """ + inv_redirections = vhost_root.inv_redirections + url_parts = url_path.strip("/").split("/") + for idx in range(len(url_parts), -1, -1): + test_url = "/" + "/".join(url_parts[:idx]) + if test_url in inv_redirections: + rem_url = url_parts[idx:] + return os.path.join( + "/", "/".join([inv_redirections[test_url]] + rem_url) + ) + return url_path + + ## Sessions ## + + def purge_session(self, request): + """helper method to purge a session during request handling""" + session = request.session + if session is not None: + log.debug(_("session purge")) + web_session = self.get_session_data(request, session_iface.IWebSession) + socket = web_session.ws_socket + if socket is not None: + socket.close() + session.ws_socket = None + session.expire() + # FIXME: not clean but it seems that it's the best way to reset + # session during request handling + request._secureSession = request._insecureSession = None + + def get_session_data(self, request, *args): + """helper method to retrieve session data + + @param request(server.Request): request linked to the session + @param *args(zope.interface.Interface): interface of the session to get + @return (iterator(data)): requested session data + """ + session = request.getSession() + if len(args) == 1: + return args[0](session) + else: + return (iface(session) for iface in args) + + @defer.inlineCallbacks + def get_affiliation(self, request, service, node): + """retrieve pubsub node affiliation for current user + + use cache first, and request pubsub service if not cache is found + @param request(server.Request): request linked to the session + @param service(jid.JID): pubsub service + @param node(unicode): pubsub node + @return (unicode): affiliation + """ + web_session = self.get_session_data(request, session_iface.IWebSession) + if web_session.profile is None: + raise exceptions.InternalError("profile must be set to use this method") + affiliation = web_session.get_affiliation(service, node) + if affiliation is not None: + defer.returnValue(affiliation) + else: + try: + affiliations = yield self.bridge_call( + "ps_affiliations_get", service.full(), node, web_session.profile + ) + except Exception as e: + log.warning( + "Can't retrieve affiliation for {service}/{node}: {reason}".format( + service=service, node=node, reason=e + ) + ) + affiliation = "" + else: + try: + affiliation = affiliations[node] + except KeyError: + affiliation = "" + web_session.set_affiliation(service, node, affiliation) + defer.returnValue(affiliation) + + ## Websocket (dynamic pages) ## + + def get_websocket_url(self, request): + base_url_split = self.get_ext_base_url_data(request) + if base_url_split.scheme.endswith("s"): + scheme = "wss" + else: + scheme = "ws" + + return self.get_ext_base_url(request, path=scheme, scheme=scheme) + + + ## Various utils ## + + def get_http_date(self, timestamp=None): + now = time.gmtime(timestamp) + fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format( + day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1] + ) + return time.strftime(fmt_date, now) + + ## service management ## + + def _start_service(self, __=None): + """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. + + @raise ImportError: OpenSSL is not available + @raise IOError: the certificate file doesn't exist + @raise OpenSSL.crypto.Error: the certificate file is invalid + """ + # now that we have service profile connected, we add resource for its cache + service_path = regex.path_escape(C.SERVICE_PROFILE) + cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path) + self.cache_resource.putChild(service_path.encode('utf-8'), + ProtectedFile(cache_dir)) + self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path) + session_iface.WebSession.service_cache_url = self.service_cache_url + + if self.options["connection_type"] in ("https", "both"): + try: + tls.tls_options_check(self.options) + context_factory = tls.get_tls_context_factory(self.options) + except exceptions.ConfigError as e: + log.warning( + f"There is a problem in TLS settings in your configuration file: {e}") + self.quit(2) + except exceptions.DataError as e: + log.warning( + f"Can't set TLS: {e}") + self.quit(1) + reactor.listenSSL(self.options["port_https"], self.site, context_factory) + if self.options["connection_type"] in ("http", "both"): + if ( + self.options["connection_type"] == "both" + and self.options["redirect_to_https"] + ): + reactor.listenTCP( + self.options["port"], + server.Site( + RedirectToHTTPS( + self.options["port"], self.options["port_https_ext"] + ) + ), + ) + else: + reactor.listenTCP(self.options["port"], self.site) + + @defer.inlineCallbacks + def stopService(self): + log.info(_("launching cleaning methods")) + for callback, args, kwargs in self._cleanup: + callback(*args, **kwargs) + try: + yield self.bridge_call("disconnect", C.SERVICE_PROFILE) + except Exception: + log.warning("Can't disconnect service profile") + + def run(self): + reactor.run() + + def stop(self): + reactor.stop() + + def quit(self, exit_code=None): + """Exit app when reactor is running + + @param exit_code(None, int): exit code + """ + self.stop() + sys.exit(exit_code or 0) + + +class RedirectToHTTPS(web_resource.Resource): + def __init__(self, old_port, new_port): + web_resource.Resource.__init__(self) + self.isLeaf = True + self.old_port = old_port + self.new_port = new_port + + def render(self, request): + netloc = request.URLPath().netloc.decode().replace( + f":{self.old_port}", f":{self.new_port}" + ) + url = f"https://{netloc}{request.uri.decode()}" + return web_util.redirectTo(url.encode(), request) + + +registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession) +registerAdapter( + session_iface.WebGuestSession, server.Session, session_iface.IWebGuestSession +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/session_iface.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 + +# Libervia: a SàT frontend +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +from collections import OrderedDict, deque +import os.path +import time +from typing import List, Dict, Optional + +import shortuuid +from zope.interface import Attribute, Interface +from zope.interface import implementer + +from libervia.backend.core.log import getLogger + +from libervia.web.server.classes import Notification +from libervia.web.server.constants import Const as C + + +log = getLogger(__name__) + +FLAGS_KEY = "_flags" +NOTIFICATIONS_KEY = "_notifications" +MAX_CACHE_AFFILIATIONS = 100 # number of nodes to keep in cache + + +class IWebSession(Interface): + profile = Attribute("Sat profile") + jid = Attribute("JID associated with the profile") + uuid = Attribute("uuid associated with the profile session") + identities = Attribute("Identities of XMPP entities") + + +@implementer(IWebSession) +class WebSession: + profiles_map: Dict[Optional[str], List["WebSession"]] = {} + + def __init__(self, session): + self._profile = None + self.jid = None + self.started = time.time() + # time when the backend session was started + self.backend_started = None + self.uuid = str(shortuuid.uuid()) + self.identities = {} + self.csrf_token = str(shortuuid.uuid()) + self.ws_token = str(shortuuid.uuid()) + self.ws_socket = None + self.ws_buffer = deque(maxlen=200) + self.locale = None # i18n of the pages + self.theme = None + self.pages_data = {} # used to keep data accross reloads (key is page instance) + self.affiliations = OrderedDict() # cache for node affiliations + self.profiles_map.setdefault(C.SERVICE_PROFILE, []).append(self) + log.debug( + f"session started for {C.SERVICE_PROFILE} " + f"({len(self.get_profile_sessions(C.SERVICE_PROFILE))} session(s) active)" + ) + + @property + def profile(self) -> Optional[str]: + return self._profile + + @profile.setter + def profile(self, profile: Optional[str]) -> None: + old_profile = self._profile or C.SERVICE_PROFILE + new_profile = profile or C.SERVICE_PROFILE + try: + self.profiles_map[old_profile].remove(self) + except (ValueError, KeyError): + log.warning(f"session was not registered for profile {old_profile}") + else: + nb_session_old = len(self.get_profile_sessions(old_profile)) + log.debug(f"{old_profile} has now {nb_session_old} session(s) active") + + self._profile = profile + self.profiles_map.setdefault(new_profile, []).append(self) + nb_session_new = len(self.get_profile_sessions(new_profile)) + log.debug(f"{new_profile} has now {nb_session_new} session(s) active") + + @property + def cache_dir(self): + if self.profile is None: + return self.service_cache_url + "/" + return os.path.join("/", C.CACHE_DIR, self.uuid) + "/" + + @property + def connected(self): + return self.profile is not None + + @property + def guest(self): + """True if this is a guest session""" + if self.profile is None: + return False + else: + return self.profile.startswith("guest@@") + + @classmethod + def send(cls, profile: str, data_type: str, data: dict) -> None: + """Send a message to all session + + If the session doesn't have an active websocket, the message is buffered until a + socket is available + """ + for session in cls.profiles_map.get(profile, []): + if session.ws_socket is None or not session.ws_socket.init_ok: + session.ws_buffer.append({"data_type": data_type, "data": data}) + else: + session.ws_socket.send(data_type, data) + + def on_expire(self) -> None: + profile = self._profile or C.SERVICE_PROFILE + try: + self.profiles_map[profile].remove(self) + except (ValueError, KeyError): + log.warning(f"session was not registered for profile {profile}") + else: + nb_session = len(self.get_profile_sessions(profile)) + log.debug( + f"Session for profile {profile} expired. {profile} has now {nb_session} " + f"session(s) active." + ) + + @classmethod + def get_profile_sessions(cls, profile: str) -> List["WebSession"]: + return cls.profiles_map.get(profile, []) + + def get_page_data(self, page, key): + """get session data for a page + + @param page(LiberviaPage): instance of the page + @param key(object): data key + return (None, object): value of the key + None if not found or page_data doesn't exist + """ + return self.pages_data.get(page, {}).get(key) + + def pop_page_data(self, page, key, default=None): + """like get_page_data, but remove key once value is gotten + + @param page(LiberviaPage): instance of the page + @param key(object): data key + @param default(object): value to return if key is not found + @return (object): found value or default + """ + page_data = self.pages_data.get(page) + if page_data is None: + return default + value = page_data.pop(key, default) + if not page_data: + # no need to keep unused page_data + del self.pages_data[page] + return value + + def set_page_data(self, page, key, value): + """set data to persist on reload + + @param page(LiberviaPage): instance of the page + @param key(object): data key + @param value(object): value to set + @return (object): set value + """ + page_data = self.pages_data.setdefault(page, {}) + page_data[key] = value + return value + + def set_page_flag(self, page, flag): + """set a flag for this page + + @param page(LiberviaPage): instance of the page + @param flag(unicode): flag to set + """ + flags = self.get_page_data(page, FLAGS_KEY) + if flags is None: + flags = self.set_page_data(page, FLAGS_KEY, set()) + flags.add(flag) + + def pop_page_flag(self, page, flag): + """return True if flag is set + + flag is removed if it was set + @param page(LiberviaPage): instance of the page + @param flag(unicode): flag to set + @return (bool): True if flaag was set + """ + page_data = self.pages_data.get(page, {}) + flags = page_data.get(FLAGS_KEY) + if flags is None: + return False + if flag in flags: + flags.remove(flag) + # we remove data if they are not used anymore + if not flags: + del page_data[FLAGS_KEY] + if not page_data: + del self.pages_data[page] + return True + else: + return False + + def set_page_notification(self, page, message, level=C.LVL_INFO): + """set a flag for this page + + @param page(LiberviaPage): instance of the page + @param flag(unicode): flag to set + """ + notif = Notification(message, level) + notifs = self.get_page_data(page, NOTIFICATIONS_KEY) + if notifs is None: + notifs = self.set_page_data(page, NOTIFICATIONS_KEY, []) + notifs.append(notif) + + def pop_page_notifications(self, page): + """Return and remove last page notification + + @param page(LiberviaPage): instance of the page + @return (list[Notification]): notifications if any + """ + page_data = self.pages_data.get(page, {}) + notifs = page_data.get(NOTIFICATIONS_KEY) + if not notifs: + return [] + ret = notifs[:] + del notifs[:] + return ret + + def get_affiliation(self, service, node): + """retrieve affiliation for a pubsub node + + @param service(jid.JID): pubsub service + @param node(unicode): pubsub node + @return (unicode, None): affiliation, or None if it is not in cache + """ + if service.resource: + raise ValueError("Service must not have a resource") + if not node: + raise ValueError("node must be set") + try: + affiliation = self.affiliations.pop((service, node)) + except KeyError: + return None + else: + # we replace at the top to get the most recently used on top + # so less recently used will be removed if cache is full + self.affiliations[(service, node)] = affiliation + return affiliation + + def set_affiliation(self, service, node, affiliation): + """cache affiliation for a node + + will empty cache when it become too big + @param service(jid.JID): pubsub service + @param node(unicode): pubsub node + @param affiliation(unicode): affiliation to this node + """ + if service.resource: + raise ValueError("Service must not have a resource") + if not node: + raise ValueError("node must be set") + self.affiliations[(service, node)] = affiliation + while len(self.affiliations) > MAX_CACHE_AFFILIATIONS: + self.affiliations.popitem(last=False) + + +class IWebGuestSession(Interface): + id = Attribute("UUID of the guest") + data = Attribute("data associated with the guest") + + +@implementer(IWebGuestSession) +class WebGuestSession(object): + + def __init__(self, session): + self.id = None + self.data = None
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/tasks/implicit/task_brython.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,149 @@ +#!/ur/bin/env python3 + +from ast import literal_eval +import json +from pathlib import Path +import shutil +from typing import Any, Dict + +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import utils + +from libervia.web.server.classes import Script +from libervia.web.server.constants import Const as C +from libervia.web.server.tasks import task + + +log = getLogger(__name__) + + +class Task(task.Task): + + def prepare(self): + if "brython" not in self.resource.browser_modules: + raise exceptions.CancelError("No brython module found") + + brython_js = self.build_path / "brython.js" + if not brython_js.is_file(): + installed_ver = None + else: + with brython_js.open() as f: + for line in f: + if line.startswith('// implementation ['): + installed_ver = literal_eval(line[18:])[:3] + log.debug( + f"brython v{'.'.join(str(v) for v in installed_ver)} already " + f"installed") + break + else: + log.warning( + f"brython file at {brython_js} doesn't has implementation " + f"version" + ) + installed_ver = None + + try: + import brython + try: + from brython.__main__ import implementation + except ImportError: + from brython.version import implementation + except ModuleNotFoundError as e: + log.error('"brython" module is missing, can\'t use browser code for Brython') + raise e + ver = [int(v) for v in implementation.split('.')[:3]] + if ver != installed_ver: + log.info(_("Installing Brython v{version}").format( + version='.'.join(str(v) for v in ver))) + data_path = Path(brython.__file__).parent / 'data' + # shutil has blocking method, but the task is run before we start + # the web server, so it's not a big deal + shutil.copyfile(data_path / "brython.js", brython_js) + shutil.copy(data_path / "brython_stdlib.js", self.build_path) + else: + log.debug("Brython is already installed") + + self.WATCH_DIRS = [] + self.set_common_scripts() + + def set_common_scripts(self): + for dyn_data in self.resource.browser_modules["brython"]: + url_hash = dyn_data['url_hash'] + import_url = f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}" + dyn_data.setdefault('scripts', utils.OrderedSet()).update([ + Script(src=f"/{C.BUILD_DIR}/brython.js"), + Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"), + ]) + dyn_data.setdefault('template', {})['body_onload'] = self.get_body_onload( + extra_path=[import_url]) + self.WATCH_DIRS.append(dyn_data['path'].resolve()) + + def get_body_onload(self, debug=True, cache=True, extra_path=None): + on_load_opts: Dict[str, Any] = {"pythonpath": [f"/{C.BUILD_DIR}"]} + if debug: + on_load_opts["debug"] = 1 + if cache: + on_load_opts["cache"] = True + if extra_path is not None: + on_load_opts["pythonpath"].extend(extra_path) + + return f"brython({json.dumps(on_load_opts)})" + + def copy_files(self, files_paths, dest): + for p in files_paths: + log.debug(f"copying {p}") + if p.is_dir(): + if p.name == '__pycache__': + continue + shutil.copytree(p, dest / p.name) + else: + shutil.copy(p, dest) + + async def on_dir_event(self, host, filepath, flags): + self.set_common_scripts() + await self.manager.run_task_instance(self) + + def start(self): + dyn_path = self.build_path / C.BUILD_DIR_DYN + for dyn_data in self.resource.browser_modules["brython"]: + url_hash = dyn_data['url_hash'] + if url_hash is None: + # root modules + url_prefix = dyn_data.get('url_prefix') + if url_prefix is None: + dest = self.build_path + init_dest_url = f"/{C.BUILD_DIR}/__init__.py" + else: + dest = self.build_path / url_prefix + dest.mkdir(exist_ok = True) + init_dest_url = f"/{C.BUILD_DIR}/{url_prefix}/__init__.py" + + self.copy_files(dyn_data['path'].glob('*py'), dest) + + init_file = dyn_data['path'] / '__init__.py' + if init_file.is_file(): + self.resource.dyn_data_common['scripts'].update([ + Script(src=f"/{C.BUILD_DIR}/brython.js"), + Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"), + Script(type='text/python', src=init_dest_url) + ]) + self.resource.dyn_data_common.setdefault( + "template", {})['body_onload'] = self.get_body_onload() + else: + page_dyn_path = dyn_path / url_hash + log.debug(f"using dynamic path at {page_dyn_path}") + if page_dyn_path.exists(): + log.debug("cleaning existing path") + shutil.rmtree(page_dyn_path) + + page_dyn_path.mkdir(parents=True, exist_ok=True) + log.debug("copying browser python files") + self.copy_files(dyn_data['path'].iterdir(), page_dyn_path) + + script = Script( + type='text/python', + src=f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}/__init__.py" + ) + dyn_data.setdefault('scripts', utils.OrderedSet()).add(script)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/tasks/implicit/task_js_modules.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,69 @@ +#!/ur/bin/env python3 + +import json +from pathlib import Path +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.core import exceptions +from libervia.web.server.constants import Const as C +from libervia.web.server.tasks import task + + +log = getLogger(__name__) + + +class Task(task.Task): + + async def prepare(self): + if "js" not in self.resource.browser_modules: + raise exceptions.CancelError("No JS module needed") + + async def start(self): + js_data = self.resource.browser_modules['js'] + package = js_data.get('package', {}) + package_path = self.build_path / 'package.json' + with package_path.open('w') as f: + json.dump(package, f) + + cmd = self.find_command('yarnpkg', 'yarn') + await self.runCommand(cmd, 'install', path=str(self.build_path)) + + try: + brython_map = js_data['brython_map'] + except KeyError: + pass + else: + log.info(_("creating JS modules mapping for Brython")) + js_modules_path = self.build_path / 'js_modules' + js_modules_path.mkdir(exist_ok=True) + init_path = js_modules_path / '__init__.py' + init_path.touch() + + for module_name, module_data in brython_map.items(): + log.debug(f"generating mapping for {module_name}") + if ' ' in module_name: + raise ValueError( + f"module {module_name!r} has space(s), it must not!") + module_path = js_modules_path / f"{module_name}.py" + if isinstance(module_data, str): + module_data = {'path': module_data} + try: + js_path = module_data.pop('path') + except KeyError: + raise ValueError( + f'module data for {module_name} must have a "path" key') + module_data['path'] = Path('node_modules') / js_path.strip(' /') + export = module_data.get('export') or [module_name] + export_objects = '\n'.join(f'{e} = window.{e}' for e in export) + extra_kwargs = {"build_dir": C.BUILD_DIR} + + with module_path.open('w') as f: + f.write(f"""\ +#!/usr/bin/env python3 +from browser import window, load +{module_data.get('extra_import', '')} + +load("{Path('/').joinpath(C.BUILD_DIR, module_data['path'])}") +{export_objects} +{module_data.get('extra_init', '').format(**extra_kwargs)} +""")
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/tasks/implicit/task_sass.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,69 @@ +#!/ur/bin/env python3 + +import json +from libervia.backend.core.log import getLogger +from libervia.backend.core import exceptions +from libervia.web.server.tasks import task + + +log = getLogger(__name__) + +SASS_SUFFIXES = ('.sass', '.scss') + + +class Task(task.Task): + """Compile .sass and .scss files found in themes browser paths""" + AFTER = ['js_modules'] + + async def prepare(self): + # we look for any Sass file, and cancel this task if none is found + sass_dirs = set() + for browser_path in self.resource.browser_modules.get('themes_browser_paths', []): + for p in browser_path.iterdir(): + if p.suffix in SASS_SUFFIXES: + sass_dirs.add(browser_path) + break + + if not sass_dirs: + raise exceptions.CancelError("No Sass file found") + + # we have some Sass files, we need to install the compiler + d_path = self.resource.dev_build_path + package_path = d_path / "package.json" + try: + with package_path.open() as f: + package = json.load(f) + except FileNotFoundError: + package = {} + except Exception as e: + log.error(f"Unexepected exception while parsing package.json: {e}") + + if 'node-sass' not in package.setdefault('dependencies', {}): + package['dependencies']['node-sass'] = 'latest' + with package_path.open('w') as f: + json.dump(package, f, indent=4) + + cmd = self.find_command('yarnpkg', 'yarn') + await self.runCommand(cmd, 'install', path=str(d_path)) + + self.WATCH_DIRS = list(sass_dirs) + + async def on_dir_event(self, host, filepath, flags): + if filepath.suffix in SASS_SUFFIXES: + await self.manager.run_task_instance(self) + + async def start(self): + d_path = self.resource.dev_build_path + node_sass = d_path / 'node_modules' / 'node-sass' / 'bin' / 'node-sass' + for browser_path in self.resource.browser_modules['themes_browser_paths']: + for p in browser_path.iterdir(): + if p.suffix not in SASS_SUFFIXES: + continue + await self.runCommand( + str(node_sass), + "--omit-source-map-url", + "--output-style", "compressed", + "--output", str(self.build_path), + str(p), + path=str(self.build_path) + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/tasks/manager.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import os +import os.path +from pathlib import Path +from typing import Dict +import importlib.util +from twisted.internet import defer +from libervia.backend.core.log import getLogger +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.tools import utils +from libervia.web.server.constants import Const as C +from . import implicit +from .task import Task + +log = getLogger(__name__) + +DEFAULT_SITE_LABEL = _("default site") + + +class TasksManager: + """Handle tasks of a Libervia site""" + + def __init__(self, host, site_resource): + """ + @param site_resource(LiberviaRootResource): root resource of the site to manage + """ + self.host = host + self.resource = site_resource + self.tasks_dir = self.site_path / C.TASKS_DIR + self.tasks = {} + self._build_path = None + self._current_task = None + + @property + def site_path(self): + return Path(self.resource.site_path) + + @property + def build_path(self): + """path where generated files will be build for this site""" + if self._build_path is None: + self._build_path = self.host.get_build_path(self.site_name) + return self._build_path + + @property + def site_name(self): + return self.resource.site_name + + def validate_data(self, task): + """Check workflow attributes in task""" + + for var, allowed in (("ON_ERROR", ("continue", "stop")), + ("LOG_OUTPUT", bool), + ("WATCH_DIRS", list)): + value = getattr(task, var) + + if isinstance(allowed, type): + if allowed is list and value is None: + continue + if not isinstance(value, allowed): + raise ValueError( + _("Unexpected value for {var}, {allowed} is expected.") + .format(var=var, allowed=allowed)) + else: + if not value in allowed: + raise ValueError(_("Unexpected value for {var}: {value!r}").format( + var=var, value=value)) + + async def import_task( + self, + task_name: str, + task_path: Path, + to_import: Dict[str, Path] + ) -> None: + if task_name in self.tasks: + log.debug(f"skipping task {task_name} which is already imported") + return + module_name = f"{self.site_name or C.SITE_NAME_DEFAULT}.task.{task_name}" + + spec = importlib.util.spec_from_file_location(module_name, task_path) + task_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(task_module) + task = task_module.Task(self, task_name) + if task.AFTER is not None: + for pre_task_name in task.AFTER: + log.debug( + f"task {task_name!r} must be run after {pre_task_name!r}") + try: + pre_task_path = to_import[pre_task_name] + except KeyError: + raise ValueError( + f"task {task_name!r} must be run after {pre_task_name!r}, " + f"however there is no task with such name") + await self.import_task(pre_task_name, pre_task_path, to_import) + + # we launch prepare, which is a method used to prepare + # data at runtime (e.g. set WATCH_DIRS using config) + try: + prepare = task.prepare + except AttributeError: + pass + else: + log.info(_('== preparing task "{task_name}" for {site_name} =='.format( + task_name=task_name, site_name=self.site_name or DEFAULT_SITE_LABEL))) + try: + await utils.as_deferred(prepare) + except exceptions.CancelError as e: + log.debug(f"Skipping {task_name} which cancelled itself: {e}") + return + + self.tasks[task_name] = task + self.validate_data(task) + if self.host.options['dev-mode']: + dirs = task.WATCH_DIRS or [] + for dir_ in dirs: + self.host.files_watcher.watch_dir( + dir_, auto_add=True, recursive=True, + callback=self._autorun_task, task_name=task_name) + + async def parse_tasks_dir(self, dir_path: Path) -> None: + log.debug(f"parsing tasks in {dir_path}") + tasks_paths = sorted(dir_path.glob('task_*.py')) + to_import = {} + for task_path in tasks_paths: + if not task_path.is_file(): + continue + task_name = task_path.stem[5:].lower().strip() + if not task_name: + continue + if task_name in self.tasks: + raise exceptions.ConflictError( + "A task with the name [{name}] already exists".format( + name=task_name)) + log.debug(f"task {task_name} found") + to_import[task_name] = task_path + + for task_name, task_path in to_import.items(): + await self.import_task(task_name, task_path, to_import) + + async def parse_tasks(self): + # implicit tasks are always run + implicit_path = Path(implicit.__file__).parent + await self.parse_tasks_dir(implicit_path) + # now we check if there are tasks specific to this site + if not self.tasks_dir.is_dir(): + log.debug(_("{name} has no task to launch.").format( + name = self.resource.site_name or DEFAULT_SITE_LABEL)) + return + else: + await self.parse_tasks_dir(self.tasks_dir) + + def _autorun_task(self, host, filepath, flags, task_name): + """Called when an event is received from a watched directory""" + if flags == ['create']: + return + try: + task = self.tasks[task_name] + on_dir_event_cb = task.on_dir_event + except AttributeError: + return defer.ensureDeferred(self.run_task(task_name)) + else: + return utils.as_deferred( + on_dir_event_cb, host, Path(filepath.path.decode()), flags) + + async def run_task_instance(self, task: Task) -> None: + self._current_task = task.name + log.info(_('== running task "{task_name}" for {site_name} =='.format( + task_name=task.name, site_name=self.site_name or DEFAULT_SITE_LABEL))) + os.chdir(self.site_path) + try: + await utils.as_deferred(task.start) + except Exception as e: + on_error = task.ON_ERROR + if on_error == 'stop': + raise e + elif on_error == 'continue': + log.warning(_('Task "{task_name}" failed for {site_name}: {reason}') + .format(task_name=task.name, site_name=self.site_name, reason=e)) + else: + raise exceptions.InternalError("we should never reach this point") + self._current_task = None + + async def run_task(self, task_name: str) -> None: + """Run a single task + + @param task_name(unicode): name of the task to run + """ + task = self.tasks[task_name] + await self.run_task_instance(task) + + async def run_tasks(self): + """Run all the tasks found""" + old_path = os.getcwd() + for task_name, task_value in self.tasks.items(): + await self.run_task(task_name) + os.chdir(old_path)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/tasks/task.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +from twisted.python.procutils import which +from libervia.backend.core.log import getLogger +from libervia.backend.tools.common import async_process +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from typing import Optional + +log = getLogger(__name__) + + +class Task: + """Handle tasks of a Libervia site""" + # can be "stop" or "continue" + ON_ERROR: str = "stop" + LOG_OUTPUT: bool = True + # list of directories to check for restarting this task + # Task.on_dir_event will be called if it exists, otherwise + # the task will be run and Task.start will be called + WATCH_DIRS: Optional[list] = None + # list of task names which must be prepared/started before this one + AFTER: Optional[list] = None + + def __init__(self, manager, task_name): + self.manager = manager + self.name = task_name + + @property + def host(self): + return self.manager.host + + @property + def resource(self): + return self.manager.resource + + @property + def site_path(self): + return self.manager.site_path + + @property + def build_path(self): + """path where generated files will be build for this site""" + return self.manager.build_path + + def config_get(self, key, default=None, value_type=None): + return self.host.config_get(self.resource, key=key, default=default, + value_type=value_type) + + @property + def site_name(self): + return self.resource.site_name + + def find_command(self, name, *args): + """Find full path of a shell command + + @param name(unicode): name of the command to find + @param *args(unicode): extra names the command may have + @return (unicode): full path of the command + @raise exceptions.NotFound: can't find this command + """ + names = (name,) + args + for n in names: + try: + cmd_path = which(n)[0] + except IndexError: + pass + else: + return cmd_path + raise exceptions.NotFound(_( + "Can't find {name} command, did you install it?").format(name=name)) + + def runCommand(self, command, *args, **kwargs): + kwargs['verbose'] = self.LOG_OUTPUT + return async_process.CommandProtocol.run(command, *args, **kwargs)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/utils.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 + + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +from libervia.backend.core.i18n import _ +from twisted.internet import reactor +from twisted.internet import defer +from libervia.backend.core import exceptions +from libervia.backend.core.log import getLogger +import urllib.request, urllib.parse, urllib.error + +log = getLogger(__name__) + + +def quote(value, safe="@"): + """shortcut to quote an unicode value for URL""" + return urllib.parse.quote(value, safe=safe) + + +class ProgressHandler(object): + """class to help the management of progressions""" + + handlers = {} + + def __init__(self, host, progress_id, profile): + self.host = host + self.progress_id = progress_id + self.profile = profile + + @classmethod + def _signal(cls, name, progress_id, data, profile): + handlers = cls.handlers + if profile in handlers and progress_id in handlers[profile]: + handler_data = handlers[profile][progress_id] + timeout = handler_data["timeout"] + if timeout.active(): + timeout.cancel() + cb = handler_data[name] + if cb is not None: + cb(data) + if name == "started": + pass + elif name == "finished": + handler_data["deferred"].callback(data) + handler_data["instance"].unregister_handler() + elif name == "error": + handler_data["deferred"].errback(Exception(data)) + handler_data["instance"].unregister_handler() + else: + log.error("unexpected signal: {name}".format(name=name)) + + def _timeout(self): + log.warning( + _( + "No progress received, cancelling handler: {progress_id} [{profile}]" + ).format(progress_id=self.progress_id, profile=self.profile) + ) + + def unregister_handler(self): + """remove a previously registered handler""" + try: + del self.handlers[self.profile][self.progress_id] + except KeyError: + log.warning( + _("Trying to remove unknown handler: {progress_id} [{profile}]").format( + progress_id=self.progress_id, profile=self.profile + ) + ) + else: + if not self.handlers[self.profile]: + self.handlers[self.profile] + + def register(self, started_cb=None, finished_cb=None, error_cb=None, timeout=30): + """register the signals to handle progression + + @param started_cb(callable, None): method to call when progress_started signal is received + @param finished_cb(callable, None): method to call when progress_finished signal is received + @param error_cb(callable, None): method to call when progress_error signal is received + @param timeout(int): progress time out + if nothing happen in this progression during this delay, + an exception is raised + @return (D(dict[unicode,unicode])): a deferred called when progression is finished + """ + handler_data = self.handlers.setdefault(self.profile, {}).setdefault( + self.progress_id, {} + ) + if handler_data: + raise exceptions.ConflictError( + "There is already one handler for this progression" + ) + handler_data["instance"] = self + deferred = handler_data["deferred"] = defer.Deferred() + handler_data["started"] = started_cb + handler_data["finished"] = finished_cb + handler_data["error"] = error_cb + handler_data["timeout"] = reactor.callLater(timeout, self._timeout) + return deferred + + +class SubPage(str): + """use to mark subpages when generating a page path"""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/server/websockets.py Fri Jun 02 16:49:28 2023 +0200 @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import json +from typing import Optional + +from autobahn.twisted import websocket +from autobahn.twisted import resource as resource +from autobahn.websocket import types +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger + +from . import session_iface +from .constants import Const as C + +log = getLogger(__name__) + +host = None + + +class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol): + + def __init__(self): + super().__init__() + self._init_ok: bool = False + self.__profile: Optional[str] = None + self.__session: Optional[session_iface.WebSession] = None + + @property + def init_ok(self): + return self._init_ok + + def send(self, data_type: str, data: dict) -> None: + """Send data to frontend""" + if not self._init_ok and data_type != "error": + raise exceptions.InternalError( + "send called when not initialized, this should not happend! Please use " + "WebSession.send which takes care of sending correctly the data to all " + "sessions." + ) + + data_root = { + "type": data_type, + "data": data + } + self.sendMessage(json.dumps(data_root, ensure_ascii=False).encode()) + + def close(self) -> None: + log.debug(f"closing websocket for profile {self.__profile}") + + def error(self, error_type: str, msg: str) -> None: + """Send an error message to frontend and log it locally""" + log.warning( + f"websocket error {error_type}: {msg}" + ) + self.send("error", { + "type": error_type, + "msg": msg, + }) + + def onConnect(self, request): + if "libervia-page" not in request.protocols: + raise types.ConnectionDeny( + types.ConnectionDeny.NOT_IMPLEMENTED, "No supported protocol" + ) + self._init_ok = False + cookies = {} + for cookie in request.headers.get("cookie", "").split(";"): + k, __, v = cookie.partition("=") + cookies[k.strip()] = v.strip() + session_uid = ( + cookies.get("TWISTED_SECURE_SESSION") + or cookies.get("TWISTED_SESSION") + or "" + ) + if not session_uid: + raise types.ConnectionDeny( + types.ConnectionDeny.FORBIDDEN, "No session set" + ) + try: + session = host.site.getSession(session_uid.encode()) + except KeyError: + raise types.ConnectionDeny( + types.ConnectionDeny.FORBIDDEN, "Invalid session" + ) + + session.touch() + session_data = session.getComponent(session_iface.IWebSession) + if session_data.ws_socket is not None: + log.warning(f"Session socket is already set {session_data.ws_socket=} {self=}], force closing it") + try: + session_data.ws_socket.send( + "force_close", {"reason": "duplicate connection detected"} + ) + except Exception as e: + log.warning(f"Can't force close old connection: {e}") + session_data.ws_socket = self + self.__session = session_data + self.__profile = session_data.profile or C.SERVICE_PROFILE + log.debug(f"websocket connection connected for profile {self.__profile}") + return "libervia-page" + + def on_open(self): + log.debug("websocket connection opened") + + def onMessage(self, payload: bytes, isBinary: bool) -> None: + if self.__session is None: + raise exceptions.InternalError("empty session, this should never happen") + try: + data_full = json.loads(payload.decode()) + data_type = data_full["type"] + data = data_full["data"] + except ValueError as e: + self.error( + "bad_request", + f"Not valid JSON, ignoring data ({e}): {payload!r}" + ) + return + except KeyError: + self.error( + "bad_request", + 'Invalid request (missing "type" or "data")' + ) + return + + if data_type == "init": + if self._init_ok: + self.error( + "bad_request", + "double init" + ) + self.sendClose(4400, "Bad Request") + return + + try: + profile = data["profile"] or C.SERVICE_PROFILE + token = data["token"] + except KeyError: + self.error( + "bad_request", + "Invalid init data (missing profile or token)" + ) + self.sendClose(4400, "Bad Request") + return + if (( + profile != self.__profile + or (token != self.__session.ws_token and profile != C.SERVICE_PROFILE) + )): + log.debug( + f"profile got {profile}, was expecting {self.__profile}, " + f"token got {token}, was expecting {self.__session.ws_token}, " + ) + self.error( + "Unauthorized", + "Invalid profile or token" + ) + self.sendClose(4401, "Unauthorized") + return + else: + log.debug(f"websocket connection initialized for {profile}") + self._init_ok = True + # we now send all cached data, if any + while True: + try: + session_kw = self.__session.ws_buffer.popleft() + except IndexError: + break + else: + self.send(**session_kw) + + if not self._init_ok: + self.error( + "Unauthorized", + "session not authorized" + ) + self.sendClose(4401, "Unauthorized") + return + + def on_close(self, wasClean, code, reason): + log.debug(f"closing websocket (profile: {self.__profile}, reason: {reason})") + if self.__profile is None: + log.error("self.__profile should not be None") + self.__profile = C.SERVICE_PROFILE + + if self.__session is None: + log.warning("closing a socket without attached session") + elif self.__session.ws_socket != self: + log.error("session socket is not linked to our instance") + else: + log.debug(f"reseting websocket session for {self.__profile}") + self.__session.ws_socket = None + sessions = session_iface.WebSession.get_profile_sessions(self.__profile) + log.debug(f"websocket connection for profile {self.__profile} closed") + self.__profile = None + + @classmethod + def get_base_url(cls, secure): + return "ws{sec}://localhost:{port}".format( + sec="s" if secure else "", + port=host.options["port_https" if secure else "port"], + ) + + @classmethod + def get_resource(cls, secure): + factory = websocket.WebSocketServerFactory(cls.get_base_url(secure)) + factory.protocol = cls + return resource.WebSocketResource(factory)
--- a/setup.py Thu Jun 01 21:42:02 2023 +0200 +++ b/setup.py Fri Jun 02 16:49:28 2023 +0200 @@ -21,8 +21,7 @@ import os NAME = "libervia-web" -# NOTE: directory is still "libervia" for compatibility reason, should be changed for 0.9 -DIR_NAME = "libervia" +DIR_NAME = "libervia/web" install_requires = [ "libervia-backend == 0.9.*", @@ -63,9 +62,9 @@ version=VERSION, description="Web frontend for Libervia", long_description=long_description, - author="Association « Salut à Toi »", + author="Libervia Dev Team", author_email="contact@goffi.org", - url="https://www.salut-a-toi.org", + url="https://www.libervia.org", classifiers=[ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", @@ -79,7 +78,7 @@ "Operating System :: POSIX :: Linux", "Topic :: Communications :: Chat", ], - packages=["libervia", "libervia.common", "libervia.server", "twisted.plugins"], + packages=["libervia.web", "libervia.web.common", "libervia.web.server", "twisted.plugins"], include_package_data=True, data_files=[(os.path.join("share", "doc", NAME), ["COPYING", "README", "INSTALL"])] + [ @@ -88,13 +87,13 @@ ], entry_points={ "console_scripts": [ - "libervia-web = libervia.server.launcher:Launcher.run", + "libervia-web = libervia.web.server.launcher:Launcher.run", ], }, zip_safe=False, setup_requires=["setuptools_scm"] if is_dev_version else [], use_scm_version=libervia_dev_version if is_dev_version else False, install_requires=install_requires, - package_data={"libervia": ["VERSION"]}, + package_data={"libervia.web": ["VERSION"]}, python_requires=">=3.7", )
--- a/twisted/plugins/libervia_server.py Thu Jun 01 21:42:02 2023 +0200 +++ b/twisted/plugins/libervia_server.py Fri Jun 02 16:49:28 2023 +0200 @@ -33,13 +33,13 @@ import re import os.path -import libervia -import sat +import libervia.web +import libervia.backend -from libervia.server.constants import Const as C +from libervia.web.server.constants import Const as C -from sat.core.i18n import _ -from sat.tools import config +from libervia.backend.core.i18n import _ +from libervia.backend.tools import config from zope.interface import implementer @@ -51,22 +51,22 @@ RE_VER_POST = re.compile(r"\.post[0-9]+") -if RE_VER_POST.sub("", libervia.__version__) != RE_VER_POST.sub("", sat.__version__): +if RE_VER_POST.sub("", libervia.web.__version__) != RE_VER_POST.sub("", libervia.backend.__version__): import sys sys.stderr.write( - """sat module version ({sat_version}) and {current_app} version ({current_version}) mismatch + """libervia.backend module version ({sat_version}) and {current_app} version ({current_version}) mismatch -sat module is located at {sat_path} +libervia.backend module is located at {sat_path} libervia module is located at {libervia_path} Please be sure to have the same version running """.format( - sat_version=sat.__version__, + sat_version=libervia.backend.__version__, current_app=C.APP_NAME, - current_version=libervia.__version__, - sat_path=os.path.dirname(sat.__file__), - libervia_path=os.path.dirname(libervia.__file__), + current_version=libervia.web.__version__, + sat_path=os.path.dirname(libervia.backend.__file__), + libervia_path=os.path.dirname(libervia.web.__file__), ) ) sys.stderr.flush() @@ -101,7 +101,7 @@ DATA_DIR_DEFAULT = '' # prefix used for environment variables ENV_PREFIX = "LIBERVIA_" -# options which are in sat.conf and on command line, +# options which are in libervia.conf and on command line, # see https://twistedmatrix.com/documents/current/api/twisted.python.usage.Options.html OPT_PARAMETERS_BOTH = [['connection_type', 't', 'https', _("'http', 'https' or 'both' " "(to launch both servers)."), @@ -135,7 +135,7 @@ _('Number of tries to connect to bridge before giving up'), int], ] -# Options which are in sat.conf only +# Options which are in libervia.conf only OPT_PARAMETERS_CFG = [ ["empty_password_allowed_warning_dangerous_list", None, "", None], ["vhosts_dict", None, {}, None], @@ -158,7 +158,7 @@ """Method to initialise global modules""" # XXX: We need to configure logs before any log method is used, # so here is the best place. - from sat.core import log_config + from libervia.backend.core import log_config log_config.sat_configure(C.LOG_BACKEND_TWISTED, C, backend_data=options) @@ -205,7 +205,7 @@ def handleDeprecated(self, config_parser): """display warning and/or change option when a deprecated option if found - param config_parser(ConfigParser): read ConfigParser instance for sat.conf + param config_parser(ConfigParser): read ConfigParser instance for libervia.conf """ replacements = (("ssl_certificate", "tls_certificate"),) for old, new in replacements: @@ -231,7 +231,7 @@ gireactor.install() for opt in OPT_PARAMETERS_BOTH: # FIXME: that's a ugly way to get unicode in Libervia - # from command line or sat.conf + # from command line or libervia.conf # we should move to argparse and handle options this properly try: coerce_cb = opt[4] @@ -242,9 +242,9 @@ print(f"FIXME: {opt[0]} is not unicode") options[opt[0]] = options[opt[0]].decode("utf-8") initialise(options.parent) - from libervia.server import server + from libervia.web.server import server - return server.Libervia(options) + return server.LiberviaWeb(options) # affectation to some variable is necessary for twisted introspection to work