├── .dockerignore ├── .envrc ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── babel.cfg ├── build-requirements.txt ├── docker ├── entrypoint.sh └── env.py ├── docs ├── colours.svg └── readme-screenshot.png ├── example.env ├── example.env.py ├── mypy.ini ├── requirements.txt ├── snikket_web ├── __init__.py ├── _version.py ├── admin.py ├── colour.py ├── infra.py ├── invite.py ├── main.py ├── prosodyclient.py ├── scss │ ├── _baseline.scss │ ├── _theme.scss │ ├── app.scss │ ├── common.scss │ ├── invite.scss │ └── theme-demo.scss ├── static │ ├── img │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-256x256.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── apple │ │ │ └── en.svg │ │ ├── f-droid-badge.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── google │ │ │ ├── da_badge_web_generic.png │ │ │ ├── de_badge_web_generic.png │ │ │ ├── en_badge_web_generic.png │ │ │ ├── es_badge_web_generic.png │ │ │ ├── fr_badge_web_generic.png │ │ │ ├── id_badge_web_generic.png │ │ │ ├── it_badge_web_generic.png │ │ │ ├── ja_badge_web_generic.png │ │ │ ├── pl_badge_web_generic.png │ │ │ ├── ru_badge_web_generic.png │ │ │ └── sv_badge_web_generic.png │ │ ├── icons.svg │ │ ├── illus-empty.svg │ │ ├── invite-bg.jpg │ │ ├── line.png │ │ ├── noise.png │ │ ├── safari-pinned-tab.svg │ │ ├── snikket-logo-text.svg │ │ ├── snikket-logo.svg │ │ └── tutorial-scan.png │ └── js │ │ ├── invite-magic.js │ │ └── qrcode.min.js ├── templates │ ├── _footer.html │ ├── about.html │ ├── admin_app.html │ ├── admin_circles.html │ ├── admin_create_circle_chat.html │ ├── admin_create_circle_group_chat_form.html │ ├── admin_create_invite.html │ ├── admin_create_invite_form.html │ ├── admin_debug_user.html │ ├── admin_delete_circle.html │ ├── admin_delete_user.html │ ├── admin_edit_circle.html │ ├── admin_edit_invite.html │ ├── admin_edit_user.html │ ├── admin_home.html │ ├── admin_invites.html │ ├── admin_reset_user_password.html │ ├── admin_system.html │ ├── admin_users.html │ ├── app.html │ ├── backend_error.html │ ├── base.html │ ├── copy-snippet.html │ ├── demo.html │ ├── exception.html │ ├── generic_http_error.html │ ├── internal_error.html │ ├── invite.html │ ├── invite_invalid.html │ ├── invite_register.html │ ├── invite_reset.html │ ├── invite_reset_success.html │ ├── invite_reset_view.html │ ├── invite_success.html │ ├── invite_view.html │ ├── library.j2 │ ├── login.html │ ├── policies.html │ ├── security.txt │ ├── unauth.html │ ├── user_home.html │ ├── user_logout.html │ ├── user_manage_data.html │ ├── user_passwd.html │ └── user_profile.html ├── translations │ ├── da │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── es_MX │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── id │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── ja │ │ └── LC_MESSAGES │ │ │ └── messages.po │ ├── messages.pot │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── sv │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── uk │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ └── zh_Hans_CN │ │ └── LC_MESSAGES │ │ ├── messages.mo │ │ └── messages.po ├── user.py └── xmpputil.py └── tools ├── icons.list └── import-icons.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python3 2 | 3 | export QUART_APP='snikket_web:create_app()' 4 | export QUART_ENV=development 5 | export QUART_DEBUG=1 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - "release/*" 8 | pull_request: 9 | branches: 10 | - master 11 | - "release/*" 12 | workflow_dispatch: 13 | 14 | jobs: 15 | mypy: 16 | runs-on: ubuntu-latest 17 | 18 | name: 'typecheck: mypy' 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-python@v2 23 | with: 24 | python-version: '3.9' 25 | - name: Install 26 | run: | 27 | set -euo pipefail 28 | pip install mypy 29 | pip install -r requirements.txt 30 | pip install -r build-requirements.txt 31 | - name: Typecheck 32 | run: | 33 | python -m mypy --config mypy.ini -p snikket_web 34 | 35 | linting: 36 | runs-on: ubuntu-latest 37 | 38 | name: 'lint: flake8' 39 | 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: actions/setup-python@v2 43 | with: 44 | python-version: '3.9' 45 | - name: Install 46 | run: | 47 | set -euo pipefail 48 | pip install flake8 flake8-print 49 | - name: Linting 50 | run: | 51 | python -m flake8 snikket_web 52 | 53 | translation-check: 54 | runs-on: ubuntu-latest 55 | 56 | name: 'lint: i18n' 57 | 58 | steps: 59 | - uses: actions/checkout@v2 60 | - uses: actions/setup-python@v2 61 | with: 62 | python-version: '3.9' 63 | - name: Install 64 | run: | 65 | set -euo pipefail 66 | pip install flask-babel 67 | - name: Linting 68 | run: | 69 | sed -ri '/^"POT-Creation-Date: /d;/^"Generated-By: /d' snikket_web/translations/messages.pot 70 | git add snikket_web/translations/messages.pot 71 | make extract_translations 72 | sed -ri '/^"POT-Creation-Date: /d;/^"Generated-By: /d' snikket_web/translations/messages.pot 73 | git diff --exit-code --color -- snikket_web/translations/messages.pot 74 | 75 | 76 | build: 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - uses: actions/checkout@v2 81 | - name: Build the Docker image 82 | run: >- 83 | docker build . 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | /.local 3 | __pycache__ 4 | /snikket_web/static/css/*.css 5 | /snikket_web/translations/*/LC_MESSAGES/*.mo 6 | /.env 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim AS build 2 | 3 | RUN set -eu; \ 4 | export DEBIAN_FRONTEND=noninteractive ; \ 5 | apt-get update ; \ 6 | apt-get install -y --no-install-recommends \ 7 | python3 python3-mypy python3-dotenv python3-toml python3-babel python3-distutils \ 8 | sassc make; 9 | 10 | COPY Makefile /opt/snikket-web-portal/Makefile 11 | COPY snikket_web/ /opt/snikket-web-portal/snikket_web 12 | COPY babel.cfg /opt/snikket-web-portal/babel.cfg 13 | 14 | WORKDIR /opt/snikket-web-portal 15 | 16 | RUN make 17 | 18 | 19 | FROM debian:bookworm-slim 20 | 21 | ARG BUILD_SERIES=dev 22 | ARG BUILD_ID=0 23 | 24 | COPY docker/env.py /etc/snikket-web-portal/env.py 25 | 26 | ENV SNIKKET_WEB_PYENV=/etc/snikket-web-portal/env.py 27 | 28 | ENV SNIKKET_WEB_PROSODY_ENDPOINT=http://127.0.0.1:5280/ 29 | 30 | WORKDIR /opt/snikket-web-portal 31 | 32 | RUN set -eu; \ 33 | export DEBIAN_FRONTEND=noninteractive ; \ 34 | apt-get update ; \ 35 | apt-get install -y --no-install-recommends \ 36 | netcat-traditional python3 python3-setuptools python3-pip \ 37 | python3-aiohttp python3-email-validator python3-flask-babel \ 38 | python3-flaskext.wtf python3-hsluv python3-hypercorn \ 39 | python3-quart python3-typing-extensions python3-wtforms ; \ 40 | pip3 install --break-system-packages environ-config ; \ 41 | apt-get remove -y --purge python3-pip python3-setuptools; \ 42 | apt-get clean ; rm -rf /var/lib/apt/lists; \ 43 | rm -rf /root/.cache; 44 | 45 | HEALTHCHECK CMD nc -zv ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE:-127.0.0.1} ${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT:-5765} 46 | 47 | COPY --from=build /opt/snikket-web-portal/snikket_web/ /opt/snikket-web-portal/snikket_web 48 | COPY babel.cfg /opt/snikket-web-portal/babel.cfg 49 | 50 | RUN echo "$BUILD_SERIES $BUILD_ID" > /opt/snikket-web-portal/.app_version 51 | 52 | ADD docker/entrypoint.sh /entrypoint.sh 53 | ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | scss_files = $(filter-out snikket_web/scss/_%.scss,$(wildcard snikket_web/scss/*.scss)) 2 | scss_includes = $(filter snikket_web/scss/_%.scss,$(wildcard snikket_web/scss/*.scss)) 3 | generated_css_files = $(patsubst snikket_web/scss/%.scss,snikket_web/static/css/%.css,$(scss_files)) 4 | 5 | translation_basepath = snikket_web/translations 6 | pot_file = $(translation_basepath)/messages.pot 7 | 8 | PYTHON3 ?= python3 9 | SCSSC ?= sassc --load-path snikket_web/scss/ 10 | 11 | all: build_css compile_translations 12 | 13 | build_css: $(generated_css_files) 14 | 15 | $(generated_css_files): snikket_web/static/css/%.css: snikket_web/scss/%.scss $(scss_files) $(scss_includes) 16 | mkdir -p snikket_web/static/css/ 17 | $(SCSSC) "$<" "$@" 18 | 19 | clean: 20 | rm -f $(generated_css_files) 21 | 22 | extract_translations: 23 | pybabel extract -F babel.cfg -k _l -o $(pot_file) . 24 | 25 | update_translations: extract_translations 26 | @echo "This has been deprecated as translations are now managed by weblate." 27 | @echo "Use extract_translations only." 28 | @false 29 | 30 | force_update_translations: extract_translations 31 | pybabel update -i $(pot_file) -d $(translation_basepath) 32 | 33 | compile_translations: 34 | -pybabel compile -d $(translation_basepath) 35 | 36 | 37 | .PHONY: build_css clean update_translations compile_translations extract_translations force_update_translations 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snikket Web Portal 2 | 3 | ![Screenshot of the app](docs/readme-screenshot.png) 4 | 5 | ## Development quickstart 6 | 7 | ```console 8 | $ direnv allow 9 | $ cp example.env .env 10 | $ $EDITOR .env # to adapt the configuration to your needs 11 | $ pip install -r requirements.txt 12 | $ pip install -r build-requirements.txt 13 | $ make 14 | $ quart run 15 | ``` 16 | 17 | ## Configuring 18 | 19 | ### Purely via environment variables 20 | 21 | For a list of required and understood environment variables as well as their 22 | semantics, please refer to [`example.env`](example.env). 23 | 24 | ### Via python code 25 | 26 | In addition to statically setting environment variables, it is possible to 27 | initialise the environment variables in a python file. To do that, pass the 28 | path to the python file as `SNIKKET_WEB_PYENV` environment variable. 29 | 30 | The python file is evaluated before further environment variable processing 31 | takes place. Every name defined in that file which begins with an upper case 32 | ASCII letter is included in the processing of environment variables for 33 | configuration purposes. 34 | 35 | For a (non-productive) example of such a file, see `example.env.py`. 36 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: snikket_web/**.py] 2 | [jinja2: snikket_web/templates/**.html] 3 | [jinja2: snikket_web/templates/**.j2] 4 | -------------------------------------------------------------------------------- /build-requirements.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | python-dotenv~=0.15 3 | types-toml 4 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export SNIKKET_WEB_DOMAIN="$SNIKKET_DOMAIN" 4 | if [ -n "${SNIKKET_SITE_NAME:-}" ]; then 5 | export SNIKKET_WEB_SITE_NAME="$SNIKKET_SITE_NAME" 6 | fi 7 | 8 | export SNIKKET_WEB_TOS_URI="${SNIKKET_TOS_URI}" 9 | export SNIKKET_WEB_PRIVACY_URI="${SNIKKET_PRIVACY_URI}" 10 | export SNIKKET_WEB_ABUSE_EMAIL="${SNIKKET_ABUSE_EMAIL}" 11 | export SNIKKET_WEB_SECURITY_EMAIL="${SNIKKET_SECURITY_EMAIL}" 12 | 13 | export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE-127.0.0.1}" 14 | export SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT="${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT-5765}" 15 | 16 | exec hypercorn -b "${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_INTERFACE}:${SNIKKET_TWEAK_PORTAL_INTERNAL_HTTP_PORT}" --access-logfile=- --log-file=- 'snikket_web:create_app()' 17 | -------------------------------------------------------------------------------- /docker/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | import sys 4 | 5 | _secret_key_path = "/etc/snikket-web-portal/secret_key" 6 | 7 | if "SNIKKET_WEB_SECRET_KEY" in os.environ: 8 | print("Using SNIKKET_WEB_SECRET_KEY from environment") 9 | else: 10 | try: 11 | with open(_secret_key_path, "r") as f: 12 | SNIKKET_WEB_SECRET_KEY = f.read() 13 | print("Restored SNIKKET_WEB_SECRET_KEY from", _secret_key_path) 14 | except FileNotFoundError: 15 | print("Generating SNIKKET_WEB_SECRET_KEY ...") 16 | SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32) 17 | old_mask = os.umask(0o077) 18 | with open(_secret_key_path, "x") as f: 19 | f.write(SNIKKET_WEB_SECRET_KEY) 20 | os.umask(old_mask) 21 | print("SNIKKET_WEB_SECRET_KEY persisted to", _secret_key_path) 22 | 23 | # Ensure that the above output is printed, even if nothing else is. 24 | sys.stdout.flush() 25 | sys.stderr.flush() 26 | -------------------------------------------------------------------------------- /docs/colours.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 17 | 19 | image/svg+xml 20 | 22 | 23 | 24 | 25 | 27 | 47 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 166 | 173 | 180 | 187 | 194 | 201 | 208 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 306 | 313 | 320 | 327 | 334 | 341 | 348 | 355 | 362 | 369 | 370 | -------------------------------------------------------------------------------- /docs/readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/docs/readme-screenshot.png -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # REQUIRED SETTINGS 2 | # ================= 3 | 4 | # Secret key used to guard forms and sessions. 5 | # 6 | # This must be both reasonably constant and secret. If the secret gets 7 | # compromised, you can change it (without having to worry about the "constant" 8 | # requirement). 9 | # 10 | # if not constant: 11 | # - sessions will be lost on each server restart 12 | # 13 | # if not secret: 14 | # - users may be able to forge sessions 15 | # - attackers may be able to execute things on a properly authenticated user’s 16 | # behalf. 17 | # - other bad things. 18 | SNIKKET_WEB_SECRET_KEY= 19 | 20 | # URL (without trailing /) of the prosody HTTP server. 21 | # 22 | # This must be set for anything to work correctly. 23 | # 24 | # NOTE: If this does not point at localhost, it MUST use https. Otherwise, 25 | # passwords will be transmitted in plaintext through insecure channels. 26 | SNIKKET_WEB_PROSODY_ENDPOINT='http://localhost:5280' 27 | 28 | # The domain name of the Snikket server 29 | # 30 | # This must be set for login to work correctly. 31 | SNIKKET_WEB_DOMAIN='localhost' 32 | 33 | # A human-friendly name for the Snikket server. 34 | # 35 | # This should not be too wide when rendered to fit into the layout. 36 | # Defaults to SNIKKET_WEB_DOMAIN. 37 | # 38 | #SNIKKET_WEB_SITE_NAME 39 | 40 | 41 | # OPTIONAL SETTINGS 42 | # ================= 43 | 44 | # How long browers may cache avatars 45 | # 46 | # Setting this to zero forces browsers to check if their locally cached copy 47 | # of an avatar is still up-to-date on every request; if it is, the avatar is 48 | # not re-transferred. 49 | # 50 | #SNIKKET_WEB_AVATAR_CACHE_TTL=1800 51 | -------------------------------------------------------------------------------- /example.env.py: -------------------------------------------------------------------------------- 1 | # Please see example.env for a detailed list of supported environment 2 | # variables as well as their semantics. 3 | 4 | # NOTE: this file is not meant for production use. Due to the non-constant 5 | # secret key, each server restart will log out all users from the web portal. 6 | 7 | import secrets 8 | SNIKKET_WEB_SECRET_KEY = secrets.token_urlsafe(nbytes=32) 9 | SNIKKET_WEB_PROSODY_ENDPOINT = "http://localhost:5280" 10 | SNIKKET_WEB_DOMAIN = "localhost" 11 | # SNIKKET_WEB_AVATAR_CACHE_TTL = 1800 12 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | #warn_return_any = True 4 | warn_unused_configs = True 5 | disallow_untyped_calls = True 6 | disallow_untyped_defs = True 7 | disallow_incomplete_defs = True 8 | #check_untyped_defs = True 9 | disallow_untyped_decorators = True 10 | #disallow_any_unimported = True 11 | #disallow_any_expr = True 12 | #disallow_any_decorated = True 13 | disallow_any_explicit = False 14 | #disallow_any_generics = True 15 | disallow_subclassing_any = True 16 | no_implicit_optional = True 17 | warn_redundant_casts = True 18 | warn_unused_ignores = True 19 | warn_unreachable = True 20 | 21 | [mypy-hsluv.*] 22 | ignore_missing_imports = True 23 | 24 | [mypy-flask_wtf.*] 25 | ignore_missing_imports = True 26 | 27 | [mypy-flask_babel.*] 28 | ignore_missing_imports = True 29 | 30 | [mypy-wtforms.*] 31 | ignore_missing_imports = True 32 | 33 | [mypy-environ.*] 34 | ignore_missing_imports = True 35 | 36 | [mypy-babel.*] 37 | ignore_missing_imports = True 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.8,<3.9 2 | quart~=0.18,<0.19 3 | flask-wtf~=1.1,<1.2 4 | hsluv~=5.0 5 | flask-babel~=2.0,<3 6 | email-validator~=1.3 7 | environ-config~=20.0 8 | wtforms~=3.0,<4 9 | typing-extensions 10 | werkzeug~=2.2,<3 11 | -------------------------------------------------------------------------------- /snikket_web/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import logging 4 | import os 5 | import pathlib 6 | import typing 7 | 8 | import aiohttp 9 | 10 | import quart.flask_patch 11 | 12 | import quart 13 | from quart import ( 14 | url_for, 15 | render_template, 16 | current_app, 17 | redirect, 18 | jsonify, 19 | ) 20 | 21 | import werkzeug.exceptions 22 | 23 | import environ 24 | 25 | from . import colour, infra 26 | from ._version import version # noqa:F401 27 | 28 | 29 | async def proc() -> typing.Dict[str, typing.Any]: 30 | def url_for_avatar(entity: str, hash_: str, 31 | **kwargs: typing.Any) -> str: 32 | return url_for( 33 | "main.avatar", 34 | from_=base64.urlsafe_b64encode( 35 | entity.encode("utf-8"), 36 | ).decode("ascii").rstrip("="), 37 | code=base64.urlsafe_b64encode( 38 | binascii.a2b_hex(hash_)[:8], 39 | ).decode("ascii").rstrip("="), 40 | **kwargs 41 | ) 42 | 43 | try: 44 | user_info = await infra.client.get_user_info() 45 | except (aiohttp.ClientError, werkzeug.exceptions.HTTPException): 46 | user_info = {} 47 | 48 | return { 49 | "url_for_avatar": url_for_avatar, 50 | "text_to_css": colour.text_to_css, 51 | "lang": infra.selected_locale(), 52 | "user_info": user_info, 53 | "is_in_debug_mode": current_app.debug, 54 | } 55 | 56 | 57 | def autosplit(s: typing.Union[str, typing.List[str]]) -> typing.List[str]: 58 | if isinstance(s, str): 59 | return s.split() 60 | return s 61 | 62 | 63 | async def render_exception_template( 64 | template: str, 65 | exc: Exception, 66 | error_id: str, 67 | ) -> str: 68 | more: typing.Dict[str, str] = {} 69 | if current_app.debug: 70 | import traceback 71 | more.update( 72 | traceback="".join(traceback.format_exception( 73 | type(exc), 74 | exc, 75 | exc.__traceback__, 76 | )), 77 | ) 78 | 79 | return await render_template( 80 | template, 81 | exception_short=str( 82 | ".".join([ 83 | type(exc).__module__, 84 | type(exc).__qualname__, 85 | ]), 86 | ), 87 | error_id=error_id, 88 | **more, 89 | ) 90 | 91 | 92 | async def backend_error_handler(exc: Exception) -> quart.Response: 93 | error_id = infra.generate_error_id() 94 | current_app.logger.error( 95 | "error_id=%s returning 503 status page for exception", 96 | error_id, 97 | exc_info=exc, 98 | ) 99 | return quart.Response( 100 | await render_exception_template( 101 | "backend_error.html", 102 | exc, 103 | error_id, 104 | ), 105 | status=503, 106 | ) 107 | 108 | 109 | async def generic_http_error( 110 | exc: werkzeug.exceptions.HTTPException, 111 | ) -> quart.Response: 112 | return quart.Response( 113 | await render_template( 114 | "generic_http_error.html", 115 | status=exc.code, 116 | description=exc.description, 117 | name=exc.name, 118 | ), 119 | status=exc.code, 120 | ) 121 | 122 | 123 | async def generic_error_handler( 124 | exc: Exception, 125 | ) -> quart.Response: 126 | error_id = infra.generate_error_id() 127 | current_app.logger.error( 128 | "error_id=%s returning 500 status page for exception", 129 | error_id, 130 | exc_info=exc, 131 | ) 132 | return quart.Response( 133 | await render_exception_template( 134 | "internal_error.html", 135 | exc, 136 | error_id, 137 | ), 138 | status=500, 139 | ) 140 | 141 | 142 | @environ.config(prefix="SNIKKET_WEB") 143 | class AppConfig: 144 | secret_key = environ.var() 145 | prosody_endpoint = environ.var() 146 | domain = environ.var() 147 | site_name = environ.var("") 148 | avatar_cache_ttl = environ.var(1800, converter=int) 149 | languages = environ.var([ 150 | # Keep `en` as the first language, because it is used as a fallback 151 | # if the language negotiation cannot find another match. It is more 152 | # likely that users are able to read english (or find a suitable 153 | # online translator) than, for instance, danish. 154 | "en", 155 | "da", 156 | "de", 157 | "fr", 158 | "id", 159 | "it", 160 | "pl", 161 | "ru", 162 | "sv", 163 | "uk", 164 | "zh_Hans_CN", 165 | ], converter=autosplit) 166 | apple_store_url = environ.var( 167 | "https://apps.apple.com/us/app/snikket/id1545164189", 168 | ) 169 | # Default limit of 1 MiB is what was discovered to be the effective limit 170 | # in #67, hence we set that here for now. 171 | # Future versions may change this default, and the standard deployment 172 | # tools may also very well override it. 173 | max_avatar_size = environ.var(1024*1024, converter=int) 174 | show_metrics = environ.bool_var(True) 175 | tos_uri = environ.var("") 176 | privacy_uri = environ.var("") 177 | abuse_email = environ.var("") 178 | security_email = environ.var("") 179 | 180 | 181 | _UPPER_CASE = "".join(map(chr, range(ord("A"), ord("Z")+1))) 182 | 183 | 184 | def create_app() -> quart.Quart: 185 | try: 186 | env_init = os.environ["SNIKKET_WEB_PYENV"] 187 | except KeyError: 188 | pass 189 | else: 190 | import runpy 191 | init_vars = runpy.run_path(env_init) 192 | for name, value in init_vars.items(): 193 | if not name: 194 | continue 195 | if name[0] not in _UPPER_CASE: 196 | continue 197 | os.environ[name] = value 198 | 199 | config = environ.to_config(AppConfig) 200 | 201 | app = quart.Quart(__name__) 202 | app.config["LANGUAGES"] = config.languages 203 | app.config["SECRET_KEY"] = config.secret_key 204 | app.config["PROSODY_ENDPOINT"] = config.prosody_endpoint 205 | app.config["SNIKKET_DOMAIN"] = config.domain 206 | app.config["SITE_NAME"] = config.site_name or config.domain 207 | app.config["AVATAR_CACHE_TTL"] = config.avatar_cache_ttl 208 | app.config["APPLE_STORE_URL"] = config.apple_store_url 209 | app.config["MAX_AVATAR_SIZE"] = config.max_avatar_size 210 | app.config["SHOW_METRICS"] = config.show_metrics 211 | app.config["TOS_URI"] = config.tos_uri 212 | app.config["PRIVACY_URI"] = config.privacy_uri 213 | app.config["ABUSE_EMAIL"] = config.abuse_email 214 | app.config["SECURITY_EMAIL"] = config.security_email 215 | app.config["SESSION_COOKIE_SECURE"] = True 216 | app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 217 | 218 | app.context_processor(proc) 219 | app.register_error_handler( 220 | aiohttp.ClientConnectorError, 221 | backend_error_handler, 222 | ) 223 | app.register_error_handler( 224 | werkzeug.exceptions.HTTPException, 225 | generic_http_error, # type:ignore 226 | ) 227 | app.register_error_handler( 228 | Exception, 229 | generic_error_handler, 230 | ) 231 | 232 | @app.route("/") 233 | async def index() -> werkzeug.Response: 234 | if infra.client.has_session: 235 | return redirect(url_for('user.index')) 236 | 237 | return redirect(url_for('main.login')) 238 | 239 | @app.route("/site.webmanifest") 240 | def site_manifest() -> quart.Response: 241 | # this is needed for icons 242 | return jsonify( 243 | { 244 | "name": "Snikket", 245 | "short_name": "Snikket", 246 | "icons": [ 247 | { 248 | "src": url_for( 249 | "static", 250 | filename="img/android-chrome-192x192.png", 251 | ), 252 | "sizes": "192x192", 253 | "type": "image/png" 254 | }, 255 | { 256 | "src": url_for( 257 | "static", 258 | filename="img/android-chrome-256x256.png", 259 | ), 260 | "sizes": "256x256", 261 | "type": "image/png" 262 | }, 263 | { 264 | "src": url_for( 265 | "static", 266 | filename="img/android-chrome-512x512.png", 267 | ), 268 | "sizes": "512x512", 269 | "type": "image/png" 270 | }, 271 | ], 272 | "theme_color": "#fbfdff", 273 | "background_color": "#fbfdff", 274 | } 275 | ) 276 | 277 | logging_config = app.config.get("LOGGING_CONFIG") 278 | if logging_config is not None: 279 | if isinstance(logging_config, dict): 280 | logging.config.dictConfig(logging_config) 281 | elif isinstance(logging_config, (bytes, str, pathlib.Path)): 282 | import toml 283 | with open(logging_config, "r") as f: 284 | logging_config = toml.load(f) 285 | logging.config.dictConfig(logging_config) 286 | 287 | else: 288 | logging.basicConfig(level=logging.WARNING) 289 | if app.debug: 290 | logging.getLogger("snikket_web").setLevel(logging.DEBUG) 291 | 292 | infra.babel.init_app(app) 293 | infra.client.init_app(app) 294 | infra.init_templating(app) 295 | 296 | from .main import bp as main_bp 297 | from .user import bp as user_bp 298 | from .admin import bp as admin_bp 299 | from .invite import bp as invite_bp 300 | 301 | app.register_blueprint(main_bp) 302 | app.register_blueprint(user_bp, url_prefix="/user") 303 | app.register_blueprint(admin_bp, url_prefix="/admin") 304 | app.register_blueprint(invite_bp, url_prefix="/invite") 305 | 306 | return app 307 | -------------------------------------------------------------------------------- /snikket_web/_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | version = "(unknown)" 5 | 6 | if os.path.exists(".app_version"): 7 | with open(".app_version") as f: 8 | version = f.read().strip() 9 | elif os.path.exists(".git"): 10 | try: 11 | version = subprocess.check_output([ 12 | "git", "describe", "--always" 13 | ]).strip().decode("utf8") 14 | except OSError: 15 | version = "dev (unknown)" 16 | -------------------------------------------------------------------------------- /snikket_web/colour.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | import typing 4 | 5 | import hsluv 6 | 7 | # This is essentially an implementation of XEP-0392. 8 | 9 | 10 | RGBf = typing.Tuple[float, float, float] 11 | 12 | 13 | def clip_rgb(r: float, g: float, b: float) -> RGBf: 14 | return ( 15 | min(max(r, 0), 1), 16 | min(max(g, 0), 1), 17 | min(max(b, 0), 1), 18 | ) 19 | 20 | 21 | @functools.lru_cache(128) 22 | def text_to_colour(text: str) -> RGBf: 23 | MASK = 0xffff 24 | h = hashlib.sha1() 25 | h.update(text.encode("utf-8")) 26 | hue = (int.from_bytes(h.digest()[:2], "little") & MASK) / MASK 27 | r, g, b = hsluv.hsluv_to_rgb((hue * 360, 75, 60)) 28 | # print(text, cb, cr, r, g, b) 29 | r, g, b = clip_rgb(r, g, b) 30 | return r, g, b 31 | 32 | 33 | def text_to_css(text: str) -> str: 34 | return "#{:02x}{:02x}{:02x}".format( 35 | *(round(v * 255) for v in text_to_colour(text)) 36 | ) 37 | -------------------------------------------------------------------------------- /snikket_web/infra.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import itertools 3 | import math 4 | import secrets 5 | import typing 6 | 7 | from datetime import datetime, timedelta, timezone 8 | 9 | import quart.flask_patch # noqa:F401 10 | from quart import ( 11 | current_app, 12 | request, 13 | g, 14 | ) 15 | 16 | import flask_babel 17 | import flask_wtf 18 | from flask_babel import lazy_gettext as _l 19 | import flask_babel as _ 20 | 21 | from . import prosodyclient 22 | 23 | 24 | client = prosodyclient.ProsodyClient() 25 | client.default_login_redirect = "main.login" 26 | 27 | babel = flask_babel.Babel() 28 | 29 | 30 | BYTE_UNIT_SCALE_MAP = [ 31 | "B", 32 | "kiB", 33 | "MiB", 34 | "GiB", 35 | "TiB", 36 | ] 37 | 38 | 39 | @babel.localeselector # type:ignore 40 | def selected_locale() -> str: 41 | g.language_header_accessed = True 42 | selected = request.accept_languages.best_match( 43 | current_app.config['LANGUAGES'] 44 | ) or current_app.config['LANGUAGES'][0] 45 | return selected 46 | 47 | 48 | def flatten(a: typing.Iterable, levels: int = 1) -> typing.Iterable: 49 | for i in range(levels): 50 | a = itertools.chain(*a) 51 | return a 52 | 53 | 54 | def circle_name(c: typing.Any) -> str: 55 | if c.id_ == "default" and c.name == "default": 56 | return _l("Main") 57 | return c.name 58 | 59 | 60 | def format_bytes(n: float) -> str: 61 | try: 62 | scale = max(math.floor(math.log(n, 1024)), 0) 63 | except ValueError: 64 | scale = 0 65 | try: 66 | unit = BYTE_UNIT_SCALE_MAP[scale] 67 | factor = 1024**scale 68 | except IndexError: 69 | unit = "TiB" 70 | factor = 1024**4 71 | if factor > 1: 72 | return "{:.1f} {}".format(n / factor, unit) 73 | return "{} {}".format(n, unit) 74 | 75 | 76 | def format_last_activity(timestamp: typing.Optional[int]) -> str: 77 | if timestamp is None: 78 | return _l("Never") 79 | 80 | last_active = datetime.fromtimestamp(timestamp, tz=timezone.utc) 81 | # TODO: This 'now' should use the user's local time zone, but we 82 | # don't have that information. Thus 'today'/'yesterday' may be 83 | # slightly inaccurate, but compared to alternative solutions it 84 | # should hopefully be "good enough". 85 | now = datetime.now(tz=timezone.utc) 86 | time_ago = now - last_active 87 | 88 | yesterday = now - timedelta(days=1) 89 | 90 | if ( 91 | last_active.year == now.year 92 | and last_active.month == now.month 93 | and last_active.day == now.day 94 | ): 95 | return _l("Today") 96 | elif ( 97 | last_active.year == yesterday.year 98 | and last_active.month == yesterday.month 99 | and last_active.day == yesterday.day 100 | ): 101 | return _l("Yesterday") 102 | 103 | return _.gettext( 104 | "%(time)s ago", 105 | time=flask_babel.format_timedelta(time_ago, granularity="day"), 106 | ) 107 | 108 | 109 | def template_now() -> typing.Dict[str, typing.Any]: 110 | return dict(now=lambda: datetime.now(timezone.utc)) 111 | 112 | 113 | def add_vary_language_header(resp: quart.Response) -> quart.Response: 114 | if getattr(g, "language_header_accessed", False): 115 | resp.vary.add("Accept-Language") 116 | return resp 117 | 118 | 119 | def init_templating(app: quart.Quart) -> None: 120 | app.template_filter("repr")(repr) 121 | app.template_filter("format_datetime")(flask_babel.format_datetime) 122 | app.template_filter("format_date")(flask_babel.format_date) 123 | app.template_filter("format_time")(flask_babel.format_time) 124 | app.template_filter("format_timedelta")(flask_babel.format_timedelta) 125 | app.template_filter("format_percent")(flask_babel.format_percent) 126 | app.template_filter("format_bytes")(format_bytes) 127 | app.template_filter("flatten")(flatten) 128 | app.template_filter("circle_name")(circle_name) 129 | app.template_filter("format_last_activity")(format_last_activity) 130 | app.context_processor(template_now) 131 | app.after_request(add_vary_language_header) 132 | 133 | 134 | def generate_error_id() -> str: 135 | return base64.b32encode(secrets.token_bytes(8)).decode( 136 | "ascii" 137 | ).rstrip("=") 138 | 139 | 140 | class BaseForm(flask_wtf.FlaskForm): # type:ignore 141 | def __init__(self, *args: typing.Any, **kwargs: typing.Any): 142 | meta = kwargs["meta"] = dict(kwargs.get("meta", {})) 143 | if "locales" not in meta: 144 | locale = flask_babel.get_locale() 145 | if locale: 146 | meta["locales"] = [str(locale)] 147 | 148 | super().__init__(*args, **kwargs) 149 | -------------------------------------------------------------------------------- /snikket_web/invite.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import typing 3 | import urllib.parse 4 | 5 | import aiohttp 6 | 7 | import quart.flask_patch 8 | from quart import ( 9 | Blueprint, 10 | current_app, 11 | render_template, 12 | redirect, 13 | request, 14 | url_for, 15 | session as http_session, 16 | ) 17 | 18 | import werkzeug 19 | 20 | import wtforms 21 | 22 | from flask_babel import lazy_gettext as _l, gettext 23 | 24 | from .infra import client, selected_locale, BaseForm 25 | 26 | 27 | bp = Blueprint("invite", __name__) 28 | 29 | 30 | INVITE_SESSION_JID = "invite-session-jid" 31 | 32 | MAX_IMPORT_DATA_SIZE = 5*1024*1024 # 5MB 33 | SUPPORTED_IMPORT_TYPES = ["application/xml", "text/xml"] 34 | 35 | EIMPORTTOOBIG = _l("The account data you tried to import is too large to" 36 | " upload. Please contact your Snikket operator.") 37 | 38 | # https://play.google.com/store/apps/details?id=org.snikket.android&referrer={uri|urlescape}&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1 39 | 40 | 41 | def apple_store_badge() -> str: 42 | locale = selected_locale() 43 | filename = "{}.svg".format(locale) 44 | static_path = pathlib.Path(__file__).parent / "static" / "img" / "apple" 45 | if (static_path / filename).exists(): 46 | return url_for("static", filename="img/apple/{}".format(filename)) 47 | return url_for("static", filename="img/apple/en.svg") 48 | 49 | 50 | def play_store_badge() -> str: 51 | locale = selected_locale() 52 | filename = "{}_badge_web_generic.png".format(locale) 53 | static_path = pathlib.Path(__file__).parent / "static" / "img" / "google" 54 | if (static_path / filename).exists(): 55 | return url_for("static", filename="img/google/{}".format(filename)) 56 | return url_for("static", filename="img/google/en_badge_web_generic.png") 57 | 58 | 59 | @bp.context_processor 60 | def context() -> typing.Dict[str, typing.Any]: 61 | return { 62 | "apple_store_badge": apple_store_badge, 63 | "play_store_badge": play_store_badge, 64 | } 65 | 66 | 67 | @bp.route("/") 68 | async def view_old(id_: str) -> werkzeug.Response: 69 | return redirect(url_for(".view", id_=id_)) 70 | 71 | 72 | @bp.route("//") 73 | async def view(id_: str) -> typing.Union[quart.Response, 74 | typing.Tuple[str, int], 75 | str]: 76 | try: 77 | invite = await client.get_public_invite_by_id(id_) 78 | except aiohttp.ClientResponseError as exc: 79 | if exc.status == 404: 80 | # invite expired 81 | return await render_template("invite_invalid.html"), 404 82 | raise 83 | 84 | if invite.reset_localpart is not None: 85 | return await render_template( 86 | "invite_reset_view.html", 87 | invite=invite, 88 | invite_id=id_, 89 | account_jid="{}@{}".format(invite.reset_localpart, invite.domain) 90 | ) 91 | 92 | play_store_url = ( 93 | "https://play.google.com/store/apps/details?" + 94 | urllib.parse.urlencode( 95 | ( 96 | ("id", "org.snikket.android"), 97 | ("referrer", invite.xmpp_uri), 98 | ("pcampaignid", 99 | "pcampaignidMKT-Other-global-all-co-prtnr-py-" 100 | "PartBadge-Mar2515-1"), 101 | ), 102 | ) 103 | ) 104 | apple_store_url = current_app.config["APPLE_STORE_URL"] 105 | 106 | body = await render_template( 107 | "invite_view.html", 108 | invite=invite, 109 | play_store_url=play_store_url, 110 | apple_store_url=apple_store_url, 111 | f_droid_url="market://details?id=org.snikket.android", 112 | invite_id=id_, 113 | ) 114 | return quart.Response( 115 | body, 116 | headers={ 117 | "Link": "<{}>; rel=\"alternate\"".format(invite.xmpp_uri), 118 | } 119 | ) 120 | 121 | 122 | class RegisterForm(BaseForm): 123 | localpart = wtforms.StringField( 124 | _l("Username"), 125 | ) 126 | 127 | password = wtforms.PasswordField( 128 | _l("Password"), 129 | validators=[ 130 | wtforms.validators.InputRequired(), 131 | wtforms.validators.Length(min=10), 132 | ], 133 | ) 134 | 135 | password_confirm = wtforms.PasswordField( 136 | _l("Confirm password"), 137 | validators=[wtforms.validators.InputRequired(), 138 | wtforms.validators.EqualTo( 139 | "password", 140 | _l("The passwords must match.") 141 | )] 142 | ) 143 | 144 | action_register = wtforms.SubmitField( 145 | _l("Create account") 146 | ) 147 | 148 | 149 | @bp.route("//register", methods=["GET", "POST"]) 150 | async def register(id_: str) -> typing.Union[str, werkzeug.Response]: 151 | try: 152 | invite = await client.get_public_invite_by_id(id_) 153 | except aiohttp.ClientResponseError as exc: 154 | if exc.status == 404: 155 | return redirect(url_for(".view", id_=id_)) 156 | 157 | if invite.reset_localpart is not None: 158 | return redirect(url_for(".reset", id_=id_)) 159 | form = RegisterForm() 160 | 161 | if form.validate_on_submit(): 162 | # log the user in? show a guide? no idea. 163 | try: 164 | jid = await client.register_with_token( 165 | username=form.localpart.data, 166 | password=form.password.data, 167 | token=id_, 168 | ) 169 | except aiohttp.ClientResponseError as exc: 170 | if exc.status == 409: 171 | form.localpart.errors.append( 172 | _l("That username is already taken.") 173 | ) 174 | elif exc.status == 403: 175 | form.localpart.errors.append( 176 | _l("Registration was declined for unknown reasons.") 177 | ) 178 | elif exc.status == 400: 179 | form.localpart.errors.append( 180 | _l("The username is not valid.") 181 | ) 182 | elif exc.status == 404: 183 | return redirect(url_for(".view", id_=id_)) 184 | else: 185 | raise 186 | else: 187 | http_session[INVITE_SESSION_JID] = jid 188 | await client.login(jid, form.password.data) 189 | return redirect(url_for(".success")) 190 | 191 | return await render_template( 192 | "invite_register.html", 193 | invite=invite, 194 | form=form, 195 | ) 196 | 197 | 198 | class ResetForm(BaseForm): 199 | password = wtforms.PasswordField( 200 | _l("Password"), 201 | validators=[ 202 | wtforms.validators.InputRequired(), 203 | wtforms.validators.Length(min=10), 204 | ], 205 | ) 206 | 207 | password_confirm = wtforms.PasswordField( 208 | _l("Confirm password"), 209 | validators=[wtforms.validators.InputRequired(), 210 | wtforms.validators.EqualTo( 211 | "password", 212 | _l("The passwords must match.") 213 | )] 214 | ) 215 | 216 | action_reset = wtforms.SubmitField( 217 | _l("Change password") 218 | ) 219 | 220 | 221 | @bp.route("//reset", methods=["GET", "POST"]) 222 | async def reset(id_: str) -> typing.Union[str, werkzeug.Response]: 223 | try: 224 | invite = await client.get_public_invite_by_id(id_) 225 | except aiohttp.ClientResponseError as exc: 226 | if exc.status == 404: 227 | return redirect(url_for(".view", id_=id_)) 228 | 229 | if invite.reset_localpart is None: 230 | return redirect(url_for(".register", id_=id_)) 231 | 232 | form = ResetForm() 233 | 234 | if form.validate_on_submit(): 235 | # log the user in? show a guide? no idea. 236 | try: 237 | jid = await client.register_with_token( 238 | username=invite.reset_localpart, 239 | password=form.password.data, 240 | token=id_, 241 | ) 242 | except aiohttp.ClientResponseError as exc: 243 | if exc.status == 403: 244 | form.localpart.errors.append( 245 | _l("Registration was declined for unknown reasons.") 246 | ) 247 | elif exc.status == 404: 248 | return redirect(url_for(".view", id_=id_)) 249 | else: 250 | raise 251 | else: 252 | http_session[INVITE_SESSION_JID] = jid 253 | return redirect(url_for(".reset_success")) 254 | 255 | return await render_template( 256 | "invite_reset.html", 257 | invite=invite, 258 | form=form, 259 | ) 260 | 261 | 262 | class DataImportForm(BaseForm): 263 | account_data_file = wtforms.FileField( 264 | _l("Account data file") 265 | ) 266 | 267 | action_import = wtforms.SubmitField( 268 | _l("Import data") 269 | ) 270 | 271 | 272 | @bp.route("/success", methods=["GET", "POST"]) 273 | @client.require_session() 274 | async def success() -> str: 275 | form = DataImportForm() 276 | if form.validate_on_submit(): 277 | ok = True 278 | file_info = (await request.files).get(form.account_data_file.name) 279 | if file_info is not None: 280 | mimetype = file_info.mimetype 281 | data = file_info.stream.read() 282 | if len(data) > MAX_IMPORT_DATA_SIZE: 283 | form.account_data_file.errors.append(EIMPORTTOOBIG) 284 | ok = False 285 | elif mimetype not in SUPPORTED_IMPORT_TYPES: 286 | form.account_data_file.errors.append( 287 | # not breaking the line here to avoid extract 288 | # translations failing (defensive) 289 | gettext("The account data you tried to import is in an unknown format. Please upload an XML file in XEP-0227 format (provided format: %(mimetype)s).", mimetype=mimetype), # noqa:E501 290 | ) 291 | ok = False 292 | elif len(data) > 0: 293 | await client.import_account_data(data) 294 | 295 | if ok: 296 | # Re-render success page, this time with no import option 297 | return await render_template( 298 | "invite_success.html", 299 | jid=http_session.get(INVITE_SESSION_JID, ""), 300 | migration_success=True, 301 | ) 302 | 303 | return await render_template( 304 | "invite_success.html", 305 | jid=http_session.get(INVITE_SESSION_JID, ""), 306 | migration_success=False, 307 | form=form, 308 | max_import_size=MAX_IMPORT_DATA_SIZE, 309 | import_too_big_warning_header=_l("Error"), 310 | import_too_big_warning=EIMPORTTOOBIG, 311 | ) 312 | 313 | 314 | @bp.route("/success/reset", methods=["GET", "POST"]) 315 | async def reset_success() -> str: 316 | return await render_template( 317 | "invite_reset_success.html", 318 | jid=http_session.get(INVITE_SESSION_JID, ""), 319 | ) 320 | 321 | 322 | @bp.route("/-") 323 | async def index() -> werkzeug.Response: 324 | return redirect(url_for("index")) 325 | -------------------------------------------------------------------------------- /snikket_web/main.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import typing 4 | 5 | from datetime import datetime, timedelta 6 | 7 | import aiohttp 8 | 9 | import quart 10 | import quart.flask_patch 11 | from quart import ( 12 | current_app, 13 | redirect, 14 | url_for, 15 | render_template, 16 | request, 17 | Response, 18 | flash, 19 | ) 20 | 21 | import werkzeug.exceptions 22 | 23 | import babel 24 | import wtforms 25 | 26 | import flask_wtf 27 | from flask_babel import lazy_gettext as _l, _ 28 | 29 | from . import xmpputil, _version 30 | from .infra import client, BaseForm 31 | 32 | 33 | bp = quart.Blueprint("main", __name__) 34 | 35 | 36 | class LoginForm(BaseForm): 37 | address = wtforms.StringField( 38 | _l("Address"), 39 | validators=[wtforms.validators.InputRequired()], 40 | ) 41 | 42 | password = wtforms.PasswordField( 43 | _l("Password"), 44 | validators=[wtforms.validators.InputRequired()], 45 | ) 46 | 47 | action_signin = wtforms.SubmitField( 48 | _l("Sign in"), 49 | ) 50 | 51 | 52 | @bp.route("/-") 53 | async def index() -> werkzeug.Response: 54 | return redirect(url_for("index")) 55 | 56 | 57 | ERR_CREDENTIALS_INVALID = _l("Invalid username or password.") 58 | 59 | 60 | @bp.route("/login", methods=["GET", "POST"]) 61 | async def login() -> typing.Union[str, werkzeug.Response]: 62 | if client.has_session and (await client.test_session()): 63 | return redirect(url_for('user.index')) 64 | 65 | form = LoginForm() 66 | if form.validate_on_submit(): 67 | jid = form.address.data 68 | localpart, domain, resource = xmpputil.split_jid(jid) 69 | if not localpart: 70 | localpart, domain = domain, current_app.config["SNIKKET_DOMAIN"] 71 | if domain != current_app.config["SNIKKET_DOMAIN"]: 72 | # (a) prosody throws a 400 at us and I prefer to catch that here 73 | # and (b) I don’t want to pass on this obviously not-for-here 74 | # password further than necessary. 75 | form.password.errors.append(ERR_CREDENTIALS_INVALID) 76 | else: 77 | jid = "{}@{}".format(localpart, domain) 78 | password = form.password.data 79 | try: 80 | await client.login(jid, password) 81 | except werkzeug.exceptions.Unauthorized: 82 | form.password.errors.append(ERR_CREDENTIALS_INVALID) 83 | else: 84 | await flash( 85 | _("Login successful!"), 86 | "success" 87 | ) 88 | return redirect(url_for('user.index')) 89 | 90 | return await render_template("login.html", form=form) 91 | 92 | 93 | @bp.route("/meta/about.html") 94 | async def about() -> str: 95 | version = None 96 | core_versions = {} 97 | extra_versions = {} 98 | if current_app.debug or client.is_admin_session: 99 | version = _version.version 100 | try: 101 | core_versions["Prosody"] = await client.get_server_version() 102 | except werkzeug.exceptions.Unauthorized: 103 | core_versions["Prosody"] = "unknown" 104 | 105 | if current_app.debug: 106 | extra_versions["aiohttp"] = aiohttp.__version__ 107 | extra_versions["babel"] = babel.__version__ 108 | extra_versions["wtforms"] = wtforms.__version__ 109 | extra_versions["flask-wtf"] = flask_wtf.__version__ 110 | try: 111 | extra_versions["Prosody"] = await client.get_server_version() 112 | except werkzeug.exceptions.Unauthorized: 113 | extra_versions["Prosody"] = "unknown" 114 | 115 | return await render_template( 116 | "about.html", 117 | version=version, 118 | extra_versions=extra_versions, 119 | core_versions=core_versions, 120 | ) 121 | 122 | 123 | @bp.route("/meta/demo.html") 124 | async def demo() -> str: 125 | return await render_template("demo.html") 126 | 127 | 128 | def repad(s: str) -> str: 129 | return s + "=" * (4 - len(s) % 4) 130 | 131 | 132 | @bp.route("/avatar//") 133 | async def avatar(from_: str, code: str) -> quart.Response: 134 | etag: typing.Optional[str] 135 | try: 136 | etag = request.headers["if-none-match"] 137 | except KeyError: 138 | etag = None 139 | 140 | address = base64.urlsafe_b64decode(repad(from_)).decode("utf-8") 141 | info = await client.get_avatar(address, metadata_only=True) 142 | bin_hash = binascii.a2b_hex(info["sha1"]) 143 | new_etag = base64.urlsafe_b64encode(bin_hash).decode("ascii").rstrip("=") 144 | 145 | cache_ttl = timedelta(seconds=current_app.config.get( 146 | "AVATAR_CACHE_TTL", 147 | 300, 148 | )) 149 | 150 | response = Response("", mimetype=info["type"]) 151 | response.headers["etag"] = new_etag 152 | # XXX: It seems to me that quart expects localtime(?!) in this field... 153 | response.expires = datetime.now() + cache_ttl 154 | response.headers["Content-Security-Policy"] = \ 155 | "frame-ancestors 'none'; default-src 'none'; style-src 'unsafe-inline'" 156 | 157 | if etag is not None and new_etag == etag: 158 | response.status_code = 304 159 | return response 160 | 161 | data = await client.get_avatar_data(address, info["sha1"]) 162 | if data is None: 163 | response.status_code = 404 164 | return response 165 | 166 | response.status_code = 200 167 | 168 | if request.method == "HEAD": 169 | response.content_length = len(data) 170 | return response 171 | 172 | response.set_data(data) 173 | return response 174 | 175 | 176 | @bp.route("/terms") 177 | async def terms() -> Response: 178 | if not current_app.config["TOS_URI"]: 179 | return Response("", 404) 180 | 181 | return Response("", status=303, headers={ 182 | "Location": current_app.config["TOS_URI"], 183 | }) 184 | 185 | 186 | @bp.route("/privacy") 187 | async def privacy() -> Response: 188 | if not current_app.config["PRIVACY_URI"]: 189 | return Response("", 404) 190 | 191 | return Response("", status=303, headers={ 192 | "Location": current_app.config["PRIVACY_URI"], 193 | }) 194 | 195 | 196 | # This is linked from the iOS app and about page 197 | @bp.route("/policies/") 198 | async def policies() -> str: 199 | return await render_template( 200 | "policies.html", 201 | ) 202 | 203 | 204 | @bp.route("/.well-known/security.txt") 205 | async def securitytxt() -> Response: 206 | return Response( 207 | await render_template("security.txt"), 208 | mimetype="text/plain;charset=UTF-8", 209 | ) 210 | 211 | 212 | @bp.route("/_health") 213 | async def health() -> Response: 214 | return Response("STATUS OK", content_type="text/plain") 215 | -------------------------------------------------------------------------------- /snikket_web/scss/_baseline.scss: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 100%; 3 | } 4 | 5 | body { 6 | font-family: $font-sans; 7 | color: $gray-100; 8 | } 9 | 10 | p, blockquote, ul, ol, table, dl { 11 | line-height: 1.5; 12 | margin: 1.5em 0; 13 | font-family: $font-bulk; 14 | color: inherit; 15 | } 16 | 17 | pre { 18 | line-height: 1.5; 19 | margin: 1.5em 0; 20 | } 21 | 22 | blockquote { 23 | margin-left: $w-l2; 24 | margin-right: $w-l2; 25 | } 26 | 27 | dt { 28 | font-weight: bold; 29 | } 30 | 31 | h1, h2, h3, h4, h5, h6 { 32 | /* normalise */ 33 | font-weight: 400; 34 | text-decoration: none; 35 | font-style: normal; 36 | font-family: $font-heading; 37 | color: black; 38 | } 39 | 40 | input, button, label, select, textarea, pre, code { 41 | font-size: 100%; 42 | color: inherit; 43 | line-height: 1.5; 44 | } 45 | 46 | textarea { 47 | font-family: $font-bulk; 48 | } 49 | 50 | option { 51 | padding: 0; 52 | margin: 0; 53 | } 54 | 55 | @for $n from 1 through 6 { 56 | h#{$n} { 57 | font-size: nth($h-sizes, $n); 58 | line-height: 1.5 / (nth($h-sizes, $n) / 100%); 59 | margin: 1.5em / (nth($h-sizes, $n) / 100%) 0; 60 | } 61 | } 62 | 63 | h6 { 64 | font-weight: bold; 65 | } 66 | 67 | @media screen and (max-width: $small-screen-threshold) { 68 | @for $n from 1 through 6 { 69 | h#{$n} { 70 | font-size: nth($h-small-sizes, $n); 71 | line-height: 1.5 / (nth($h-small-sizes, $n) / 100%); 72 | margin: 1.5em / (nth($h-small-sizes, $n) / 100%) 0; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /snikket_web/scss/_theme.scss: -------------------------------------------------------------------------------- 1 | $colours: ( 2 | "gray": [ 3 | #1f1b17, 4 | #3d3833, 5 | #4e4a46, 6 | #706965, 7 | #8f8983, 8 | #b1aca6, 9 | #cac3bd, 10 | #e3e1df, 11 | #f6f5f4 12 | ], 13 | "blue": [ 14 | #062243, 15 | #0f3462, 16 | #0e4276, 17 | #225994, 18 | #4182c7, 19 | #72a7e3, 20 | #9dc4f0, 21 | #b5d2f3, 22 | #e4f0fd 23 | ], 24 | "red": [ 25 | #340e03, 26 | #681f0b, 27 | #883017, 28 | #a33d21, 29 | #c95e40, 30 | #ed947c, 31 | #f2ac99, 32 | #fbc2b3, 33 | #fef1ed 34 | ], 35 | "yellow": [ 36 | #302100, 37 | #563600, 38 | #795b00, 39 | #a07501, 40 | #c79b0e, 41 | #f4ce3f, 42 | #fee577, 43 | #fef1c1, 44 | #fff8e8 45 | ], 46 | "green": [ 47 | #172f03, 48 | #244608, 49 | #407713, 50 | #548f19, 51 | #7fc644, 52 | #a1e06e, 53 | #c0ed9c, 54 | #dbf6c5, 55 | #effbe6 56 | ] 57 | ); 58 | 59 | $box-types: ( 60 | "primary": "blue", 61 | "accent": "yellow", 62 | "alert": "red", 63 | "warning": "yellow", 64 | "success": "green", 65 | "hint": "blue" 66 | ); 67 | 68 | $gray-100: nth(map-get($colours, "gray"), 1); 69 | $gray-200: nth(map-get($colours, "gray"), 2); 70 | $gray-300: nth(map-get($colours, "gray"), 3); 71 | $gray-400: nth(map-get($colours, "gray"), 4); 72 | $gray-500: nth(map-get($colours, "gray"), 5); 73 | $gray-600: nth(map-get($colours, "gray"), 6); 74 | $gray-700: nth(map-get($colours, "gray"), 7); 75 | $gray-800: nth(map-get($colours, "gray"), 8); 76 | $gray-900: nth(map-get($colours, "gray"), 9); 77 | 78 | $primary-100: nth(map-get($colours, "blue"), 1); 79 | $primary-200: nth(map-get($colours, "blue"), 2); 80 | $primary-300: nth(map-get($colours, "blue"), 3); 81 | $primary-400: nth(map-get($colours, "blue"), 4); 82 | $primary-500: nth(map-get($colours, "blue"), 5); 83 | $primary-600: nth(map-get($colours, "blue"), 6); 84 | $primary-700: nth(map-get($colours, "blue"), 7); 85 | $primary-800: nth(map-get($colours, "blue"), 8); 86 | $primary-900: nth(map-get($colours, "blue"), 9); 87 | 88 | $alert-100: nth(map-get($colours, "red"), 1); 89 | $alert-200: nth(map-get($colours, "red"), 2); 90 | $alert-300: nth(map-get($colours, "red"), 3); 91 | $alert-400: nth(map-get($colours, "red"), 4); 92 | $alert-500: nth(map-get($colours, "red"), 5); 93 | $alert-600: nth(map-get($colours, "red"), 6); 94 | $alert-700: nth(map-get($colours, "red"), 7); 95 | $alert-800: nth(map-get($colours, "red"), 8); 96 | $alert-900: nth(map-get($colours, "red"), 9); 97 | 98 | $accent-100: nth(map-get($colours, "yellow"), 1); 99 | $accent-200: nth(map-get($colours, "yellow"), 2); 100 | $accent-300: nth(map-get($colours, "yellow"), 3); 101 | $accent-400: nth(map-get($colours, "yellow"), 4); 102 | $accent-500: nth(map-get($colours, "yellow"), 5); 103 | $accent-600: nth(map-get($colours, "yellow"), 6); 104 | $accent-700: nth(map-get($colours, "yellow"), 7); 105 | $accent-800: nth(map-get($colours, "yellow"), 8); 106 | $accent-900: nth(map-get($colours, "yellow"), 9); 107 | 108 | $success-100: nth(map-get($colours, "green"), 1); 109 | $success-200: nth(map-get($colours, "green"), 2); 110 | $success-300: nth(map-get($colours, "green"), 3); 111 | $success-400: nth(map-get($colours, "green"), 4); 112 | $success-500: nth(map-get($colours, "green"), 5); 113 | $success-600: nth(map-get($colours, "green"), 6); 114 | $success-700: nth(map-get($colours, "green"), 7); 115 | $success-800: nth(map-get($colours, "green"), 8); 116 | $success-900: nth(map-get($colours, "green"), 9); 117 | 118 | /* 119 | $primary-100: $gray-100; 120 | $primary-200: $gray-200; 121 | $primary-300: $gray-300; 122 | $primary-400: $gray-400; 123 | $primary-500: $gray-500; 124 | $primary-600: $gray-600; 125 | $primary-700: $gray-700; 126 | $primary-800: $gray-800; 127 | $primary-900: $gray-900; 128 | 129 | $alert-100: $gray-100; 130 | $alert-200: $gray-200; 131 | $alert-300: $gray-300; 132 | $alert-400: $gray-400; 133 | $alert-500: $gray-500; 134 | $alert-600: $gray-600; 135 | $alert-700: $gray-700; 136 | $alert-800: $gray-800; 137 | $alert-900: $gray-900; 138 | 139 | $accent-100: $gray-100; 140 | $accent-200: $gray-200; 141 | $accent-300: $gray-300; 142 | $accent-400: $gray-400; 143 | $accent-500: $gray-500; 144 | $accent-600: $gray-600; 145 | $accent-700: $gray-700; 146 | $accent-800: $gray-800; 147 | $accent-900: $gray-900; 148 | 149 | $success-100: $gray-100; 150 | $success-200: $gray-200; 151 | $success-300: $gray-300; 152 | $success-400: $gray-400; 153 | $success-500: $gray-500; 154 | $success-600: $gray-600; 155 | $success-700: $gray-700; 156 | $success-800: $gray-800; 157 | $success-900: $gray-900; 158 | */ 159 | 160 | 161 | $w-s5: 0.0625rem; 162 | $w-s4: 0.125rem; 163 | $w-s3: 0.25rem; 164 | $w-s2: 0.5rem; 165 | $w-s1: 0.75rem; 166 | $w-0: 1rem; 167 | $w-l1: 1.5rem; 168 | $w-l2: 2rem; 169 | $w-l3: 3rem; 170 | $w-l4: 4rem; 171 | $w-l5: 6rem; 172 | $w-l6: 8rem; 173 | $w-l7: 12rem; 174 | $w-l8: 16rem; 175 | 176 | $font-sans: "Noto Sans", sans-serif; 177 | $font-serif: serif; 178 | $font-monospace: monospace; 179 | 180 | $font-heading: $font-sans; 181 | $font-bulk: $font-sans; 182 | $font-code: $font-monospace; 183 | 184 | /** 185 | * On the scaling of the headers. I’m a nerd, so here we go. 186 | * 187 | * I tried to determine a good scale a priori. It was clear to me that the 188 | * observed difference between a 48px and 64px font is much smaller than the 189 | * perceived difference between a 8px and 16px font size. 190 | * 191 | * Thus, the perception is *not* linear in the font size. 192 | * 193 | * I set the edge points to 200% and 100% (the h6 would get a bold font face) 194 | * to compensate. 195 | * 196 | * The first attempt to get a visually appealing header size scale was thus to 197 | * generate a logarithmic scale: 198 | * 199 | * numpy.logspace(np.log10(200), 2, 6, base=10) 200 | * 201 | * This leads to the following sizes: 202 | * 203 | * $_h-sizes: [200%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 100%]; 204 | * 205 | * This scale has too large differences between the larger font sizes, and too 206 | * small differences between the smaller font sizes. Thus, I tried to invert 207 | * this: 208 | * 209 | * 200 - numpy.logspace(2, np.log10(200), 6, base=10) + 100 210 | * 211 | * This leads to the following sizes: 212 | * 213 | * $_h-sizes: [200.0%, 185.13016450029647%, 168.0492089227105%, 148.42834334896025%, 125.88988734077518%, 100%]; 214 | * 215 | * While this was better, it still didn’t look quite right yet. The next 216 | * attempt was to go about a square function instead of log. The idea behind 217 | * this is that the font size is essentially one edge of a rectangle, where the 218 | * second edge depends on the first. A square function should thus generate a 219 | * nicely appealing sequence: 220 | * 221 | * Again, we want the large differences to be on the large scales, too: 222 | * 223 | * xs = numpy.linspace(5, 0, 6); 4*xs*xs + 100 224 | * 225 | * This leads to the following sizes: 226 | * 227 | * $_h-sizes: [200.0%, 164.0%, 136.0%, 116.0%, 104.0%, 100.0%]; 228 | * 229 | * While the first three headings looked nice with that, the others did not. 230 | * Further research has shown me that others use an exponential scale (instead 231 | * of a log scale), but with a rather small base (<1.6). 232 | * 233 | * Instead of taking one of the well-known factors (like golden ratio or major 234 | * second), I opted for choosing a factor which gives me a clean 200%-100% 235 | * range: 236 | * 237 | * numpy.power(math.pow(2, 1/5), numpy.linspace(5, 0, 6)) * 100 238 | * 239 | * The result (rounded to 8 digits) is: 240 | * 241 | * $_h-sizes: [200.0%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 100.0%]; 242 | * 243 | * And... This is the first logspace range. Derp. So why did I discard it in 244 | * the first place? Now that I look at it, it looks amazing. Brains are weird. 245 | */ 246 | $h-sizes: [200.0%, 174.11011266%, 151.57165665%, 131.95079108%, 114.8698355%, 100.0%]; 247 | 248 | /** 249 | * And for mobile devices, we want an even less aggressive scale. Let’s try 250 | * 150%-100%. 251 | */ 252 | $h-small-sizes: [150.0%, 138.31618672%, 127.54245006%, 117.60790225%, 108.44717712%, 100.0%]; 253 | $small-screen-threshold: 40rem; 254 | $medium-screen-threshold: 60rem; 255 | $large-screen-threshold: 80rem; 256 | -------------------------------------------------------------------------------- /snikket_web/scss/common.scss: -------------------------------------------------------------------------------- 1 | .a11y-only { 2 | position: absolute; 3 | width: 1px; 4 | height: 1px; 5 | overflow: hidden; 6 | top: -100px; 7 | } 8 | -------------------------------------------------------------------------------- /snikket_web/scss/invite.scss: -------------------------------------------------------------------------------- 1 | @import "_theme.scss"; 2 | 3 | div.powered-by { 4 | text-align: right; 5 | line-height: 1.5; 6 | 7 | > img { 8 | height: 1.5em; 9 | vertical-align: -0.2em; 10 | margin-left: 0.2em; 11 | } 12 | } 13 | 14 | div.modal { 15 | position: fixed; 16 | left: 0; 17 | right: 0; 18 | top: 0; 19 | bottom: 0; 20 | overflow-x: hidden; 21 | overflow-y: auto; 22 | background: rgba(0, 0, 0, 0.5); 23 | z-index: 1024; 24 | width: 100%; 25 | height: 100%; 26 | 27 | > div { 28 | padding: $w-l1; 29 | margin-left: auto; 30 | max-width: 40rem; 31 | margin-right: auto; 32 | 33 | > header { 34 | display: flex; 35 | flex-direction: row; 36 | 37 | > span { 38 | display: inline-block; 39 | flex: 1 1 auto; 40 | } 41 | 42 | > a.button { 43 | flex: 0 0 auto; 44 | } 45 | } 46 | } 47 | } 48 | 49 | div.install-buttons { 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | 54 | ul { 55 | display: flex; 56 | flex-direction: row; 57 | flex-wrap: wrap; 58 | justify-content: center; 59 | list-style-type: none; 60 | margin: $w-l1 0; 61 | padding: 0; 62 | } 63 | 64 | li { 65 | margin: 0; 66 | padding: 0; 67 | } 68 | } 69 | 70 | img.apple { 71 | height: $w-l2; 72 | margin: $w-s2; 73 | } 74 | 75 | img.play { 76 | height: $w-l3; 77 | } 78 | 79 | img.fdroid { 80 | height: $w-l3; 81 | } 82 | 83 | .qr { 84 | margin: $w-l1 0; 85 | display: flex; 86 | flex-direction: row; 87 | justify-content: center; 88 | 89 | > img { 90 | padding: $w-0; 91 | background: white; 92 | } 93 | } 94 | 95 | .float-right { 96 | float: right; 97 | } 98 | 99 | #tutorial-scan { 100 | width: $w-l5; 101 | margin: $w-l1; 102 | 103 | box-shadow: 104 | 0 1px 3px rgba(0, 0, 0, 0.12), 105 | 0 1px 2px rgba(0, 0, 0, 0.24); 106 | } 107 | 108 | div.form.layout-expanded .lwrap { 109 | display: flex; 110 | flex-direction: row; 111 | 112 | input.localpart-magic { 113 | display: inline-block; 114 | width: auto; 115 | flex: 1 0 auto; 116 | } 117 | 118 | span { 119 | display: inline-block; 120 | flex: 0 0 auto; 121 | background: $gray-900; 122 | border: none; 123 | border-bottom: $w-s4 solid $primary-500; 124 | margin-bottom: -$w-s4; 125 | padding: 0 $w-s3; 126 | } 127 | } 128 | 129 | .fullwidth { 130 | width: 100%; 131 | } 132 | 133 | #invite { 134 | background: url('../img/invite-bg.jpg'); 135 | background-attachment: fixed; 136 | background-size: cover; 137 | } 138 | 139 | /* dark mode */ 140 | 141 | @media (prefers-color-scheme: dark) { 142 | div.form.layout-expanded .lwrap { 143 | span { 144 | background: $gray-200; 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /snikket_web/scss/theme-demo.scss: -------------------------------------------------------------------------------- 1 | @import "./app.scss"; 2 | 3 | .accent-100 { background-color: $accent-100; } 4 | .success-100 { background-color: $success-100; } 5 | .gray-100 { background-color: $gray-100; } 6 | .alert-100 { background-color: $alert-100; } 7 | .primary-100 { background-color: $primary-100; } 8 | .accent-200 { background-color: $accent-200; } 9 | .success-200 { background-color: $success-200; } 10 | .gray-200 { background-color: $gray-200; } 11 | .alert-200 { background-color: $alert-200; } 12 | .primary-200 { background-color: $primary-200; } 13 | .accent-300 { background-color: $accent-300; } 14 | .success-300 { background-color: $success-300; } 15 | .gray-300 { background-color: $gray-300; } 16 | .alert-300 { background-color: $alert-300; } 17 | .primary-300 { background-color: $primary-300; } 18 | .accent-400 { background-color: $accent-400; } 19 | .success-400 { background-color: $success-400; } 20 | .gray-400 { background-color: $gray-400; } 21 | .alert-400 { background-color: $alert-400; } 22 | .primary-400 { background-color: $primary-400; } 23 | .accent-500 { background-color: $accent-500; } 24 | .success-500 { background-color: $success-500; } 25 | .gray-500 { background-color: $gray-500; } 26 | .alert-500 { background-color: $alert-500; } 27 | .primary-500 { background-color: $primary-500; } 28 | .accent-600 { background-color: $accent-600; } 29 | .success-600 { background-color: $success-600; } 30 | .gray-600 { background-color: $gray-600; } 31 | .alert-600 { background-color: $alert-600; } 32 | .primary-600 { background-color: $primary-600; } 33 | .accent-700 { background-color: $accent-700; } 34 | .success-700 { background-color: $success-700; } 35 | .gray-700 { background-color: $gray-700; } 36 | .alert-700 { background-color: $alert-700; } 37 | .primary-700 { background-color: $primary-700; } 38 | .accent-800 { background-color: $accent-800; } 39 | .success-800 { background-color: $success-800; } 40 | .gray-800 { background-color: $gray-800; } 41 | .alert-800 { background-color: $alert-800; } 42 | .primary-800 { background-color: $primary-800; } 43 | .accent-900 { background-color: $accent-900; } 44 | .success-900 { background-color: $success-900; } 45 | .gray-900 { background-color: $gray-900; } 46 | .alert-900 { background-color: $alert-900; } 47 | .primary-900 { background-color: $primary-900; } 48 | 49 | div.samplerow { 50 | display: flex; 51 | align-items: center; 52 | background: white; 53 | color: black; 54 | } 55 | 56 | div.samplerow.dark { 57 | background: black; 58 | color: white; 59 | } 60 | 61 | div.samplehead { 62 | flex: 1 0 auto; 63 | padding: 8px; 64 | } 65 | 66 | div.samplebox { 67 | flex: 0 0 auto; 68 | width: 32px; 69 | height: 32px; 70 | // border: 1px solid $gray-900; 71 | border-radius: 3px; 72 | margin: 8px; 73 | box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.5); 74 | } 75 | 76 | div.samplerow.dark div.samplebox { 77 | //border: 1px solid $gray-100; 78 | } 79 | 80 | body { 81 | max-width: 60em; 82 | margin-left: auto; 83 | margin-right: auto; 84 | } 85 | 86 | div.demo-columns { 87 | display: flex; 88 | } 89 | 90 | div.demo-column { 91 | flex: 1 1 1px; 92 | margin: 0 $w-l1; 93 | } 94 | 95 | div.demo-column:first-child { 96 | margin-left: 0; 97 | } 98 | 99 | div.demo-column:last-child { 100 | margin-right: 0; 101 | } 102 | 103 | body { 104 | background: 105 | url('../img/line.png'), 106 | $gray-900; 107 | background-size: 1.5em 1.5em; 108 | background-position: 0em -0.3em; 109 | } 110 | 111 | body:target { 112 | background: $gray-900; 113 | } 114 | 115 | body #enable-lines { 116 | display: none; 117 | } 118 | 119 | body:target #disable-lines { 120 | display: none; 121 | } 122 | 123 | body:target #enable-lines { 124 | display: inline; 125 | } 126 | 127 | @media screen and (max-width: 40rem) { 128 | div.demo-columns, div.demo-column { 129 | display: block; 130 | margin: 0; 131 | } 132 | } 133 | 134 | 135 | /* dark mode */ 136 | 137 | @media (prefers-color-scheme: dark) { 138 | body { 139 | background: 140 | url('../img/line.png'), 141 | $gray-100; 142 | } 143 | 144 | body:target { 145 | background: $gray-100; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /snikket_web/static/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /snikket_web/static/img/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/android-chrome-256x256.png -------------------------------------------------------------------------------- /snikket_web/static/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /snikket_web/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /snikket_web/static/img/apple/en.svg: -------------------------------------------------------------------------------- 1 | 2 | Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /snikket_web/static/img/f-droid-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/f-droid-badge.png -------------------------------------------------------------------------------- /snikket_web/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /snikket_web/static/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/favicon-32x32.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/da_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/da_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/de_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/de_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/en_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/en_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/es_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/es_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/fr_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/fr_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/id_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/id_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/it_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/it_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/ja_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/ja_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/pl_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/pl_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/ru_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/ru_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/google/sv_badge_web_generic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/google/sv_badge_web_generic.png -------------------------------------------------------------------------------- /snikket_web/static/img/invite-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/invite-bg.jpg -------------------------------------------------------------------------------- /snikket_web/static/img/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/line.png -------------------------------------------------------------------------------- /snikket_web/static/img/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/noise.png -------------------------------------------------------------------------------- /snikket_web/static/img/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 21 | 24 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /snikket_web/static/img/snikket-logo-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /snikket_web/static/img/snikket-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 23 | 27 | 31 | 32 | 39 | 43 | 47 | 48 | 56 | 63 | 67 | 71 | 72 | 80 | 87 | 91 | 96 | 97 | 104 | 111 | 112 | 114 | 115 | 117 | image/svg+xml 118 | 120 | 121 | 122 | 123 | 124 | 127 | 131 | 137 | 143 | 149 | 155 | 161 | 167 | 173 | 180 | 186 | 190 | 196 | 197 | 203 | 207 | 213 | 214 | 215 | 216 | 217 | -------------------------------------------------------------------------------- /snikket_web/static/img/tutorial-scan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/static/img/tutorial-scan.png -------------------------------------------------------------------------------- /snikket_web/static/js/invite-magic.js: -------------------------------------------------------------------------------- 1 | var open_modal = function(a_el) { 2 | var modal_id = "" + a_el.getAttribute("href").split("#")[1]; 3 | var modal_el = document.getElementById(modal_id); 4 | modal_el.setAttribute("aria-modal", "true"); 5 | modal_el.removeAttribute("aria-hidden"); 6 | modal_el.style.setProperty("display", "block"); 7 | }; 8 | 9 | var close_modal = function(modal_el) { 10 | modal_el.removeAttribute("aria-modal"); 11 | modal_el.setAttribute("aria-hidden", "true"); 12 | modal_el.style.setProperty("display", "none"); 13 | }; 14 | 15 | var find_tabbox_el = function(tab_content_el) { 16 | var current = tab_content_el; 17 | while (current) { 18 | if (current.classList.contains("tabbox")) { 19 | return current; 20 | } 21 | current = current.parentNode; 22 | }; 23 | return null; 24 | }; 25 | 26 | var clear_active_tab = function(tabbox_el) { 27 | var nav_el = tabbox_el.firstChild; 28 | var child = nav_el.firstChild; 29 | while (child) { 30 | child.setAttribute("aria-selected", "false"); 31 | child.classList.remove("active"); 32 | child = child.nextSibling; 33 | } 34 | 35 | var child = nav_el.nextSibling; 36 | while (child) { 37 | if (child.classList.contains("tab-pane")) { 38 | child.classList.remove("active"); 39 | } 40 | child = child.nextSibling; 41 | } 42 | }; 43 | 44 | var select_tab = function(tab_header_el) { 45 | var tab_id = "" + tab_header_el.getAttribute("href").split("#")[1]; 46 | var tab_el = document.getElementById(tab_id); 47 | clear_active_tab(find_tabbox_el(tab_el)); 48 | tab_el.classList.add("active"); 49 | tab_header_el.classList.add("active"); 50 | tab_header_el.setAttribute("aria-selected", "true"); 51 | }; 52 | 53 | var apply_qr_code = function(target_el) { 54 | new QRCode(target_el, target_el.dataset.qrdata); 55 | }; 56 | -------------------------------------------------------------------------------- /snikket_web/templates/_footer.html: -------------------------------------------------------------------------------- 1 |
2 |
    3 | {#- -#} 4 |
  • {% trans about_url=url_for('main.about') %}A Snikket service{% endtrans %}
  • 5 | {#- -#} 6 |
  • {% trans %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company.{% endtrans %}
  • 7 | {#- -#} 8 |
9 |
10 | -------------------------------------------------------------------------------- /snikket_web/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block head_lead %} 4 | {% trans %}About Snikket{% endtrans %} 5 | {% endblock %} 6 | {% block body %} 7 |
8 |
9 |

{% trans %}About this Service{% endtrans %}

10 |

{% trans site_name=config["SITE_NAME"] %}This is the Snikket service {{ site_name }}, running open-source software from the Snikket project.{% endtrans %}

11 |

{% trans snikket_url="https://snikket.org" %}To learn more about Snikket, visit the Snikket website.{% endtrans %}

12 | 13 |

{% trans %}View service policies{% endtrans %} 14 | 15 |

{% trans %}Licenses{% endtrans %}

16 |

{% trans agpl_url="https://www.gnu.org/licenses/agpl.html" %}The web portal software is licensed under the terms of the Affero GNU General Public License, version 3.0 or later. The full terms of the license can be reviewed using the aforementioned link.{% endtrans %}

17 |

{% trans source_url="https://github.com/snikket-im/snikket-web-portal/" %}The source code of the web portal can be downloaded and viewed in its GitHub repository.{% endtrans %}

18 |

{% trans source_url="https://material.io/resources/icons/", apache20_url="https://www.apache.org/licenses/LICENSE-2.0.txt" %}The icons used in the web portal are Google’s Material Icons, made available by Google under the terms of the Apache 2.0 License.{% endtrans %}

19 | 20 |

{% trans %}Trademarks{% endtrans %}

21 |

{% trans trademarks_url="https://snikket.org/about/trademarks/" %}“Snikket” and the parrot logo are trademarks of Snikket Community Interest Company. For more information about the trademarks, visit the Snikket Trademarks information page.{% endtrans %} 22 | 23 |

{% trans %}Software Versions{% endtrans %}

24 |
Domain: {{ config["SNIKKET_DOMAIN"] }}
25 | Web Portal{% if version %} ({{ version }}){% endif %}
26 | {%- if core_versions -%}
27 | {% for name, version in core_versions.items() %}
28 | {{ name }} ({{ version }}){% endfor %}
29 | {%- endif -%}
30 | {%- if extra_versions -%}
31 | {% for name, version in extra_versions.items() %}
32 | {{ name }} ({{ version }}){% endfor %}
33 | {%- endif -%}
34 | 35 |

36 | {%- call standard_button("back", url_for("index"), class="primary") -%} 37 | {% trans %}Back to the main page{% endtrans %} 38 | {%- endcall -%} 39 |

40 |
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_app.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% block topbar_classes %}{{ super() }} admin{% endblock %} 3 | {% block topbar_left %} 4 | {{ super() }} 5 |
{% trans %}Admin area{% endtrans %}
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_circles.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import action_button, custom_form_button, form_button, circle_name %} 3 | {% block content %} 4 |

{% trans %}Manage circles{% endtrans %}

5 |

{% trans %}Circles aim to help people who are in the same social circle find each other on your service.{% endtrans %}

6 |

{% trans %}Users who are in the same circle will see each other in their contact list. In addition, each circle may have group chats where the circle members are included.{% endtrans %}

7 | {%- if circles -%} 8 |
9 | {{- invite_form.csrf_token -}} 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for circle in circles %} 20 | 21 | 22 | 23 | 34 | 35 | {% endfor %} 36 | 37 |
{% trans %}Circle name{% endtrans %}{% trans %}Members{% endtrans %}{% trans %}Actions{% endtrans %}
{{ circle | circle_name }}{{ circle.members | length }} 24 | {%- call custom_form_button("create_link", invite_form.circles.name, circle.id_, slim=True, class="secondary accent") -%} 25 | {% trans circle_name=(circle | circle_name) %}Create invitation to circle {{ circle_name }}{% endtrans %} 26 | {%- endcall -%} 27 | {%- call action_button("people", url_for(".edit_circle", id_=circle.id_) + "#members", class="secondary") -%} 28 | {% trans circle_name=(circle | circle_name) %}Manage members of {{ circle_name }}{% endtrans %} 29 | {%- endcall -%} 30 | {%- call action_button("edit", url_for(".edit_circle", id_=circle.id_), class="primary") -%} 31 | {% trans circle_name=(circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %} 32 | {%- endcall -%} 33 |
38 | {%- else -%} 39 |
40 |
{% trans %}No circles{% endtrans %}
41 |

{% trans %}Currently, there are no circles on this service. Use the form below to create one.{% endtrans %}

42 |
43 | {%- endif -%} 44 |

{% trans %}New circle{% endtrans %}

45 |
46 | {{- create_form.csrf_token -}} 47 |

{% trans %}Create circle{% endtrans %}

48 |
49 | {{- create_form.name.label(class="required") -}} 50 | {{- create_form.name -}} 51 |
52 |
53 | {%- call form_button("create_group", create_form.action_create, class="primary") -%}{%- endcall -%} 54 |
55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_create_circle_chat.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% block content %} 3 |

{{ target_circle.name }}

4 | {%- include "admin_create_circle_group_chat_form.html" -%} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_create_circle_group_chat_form.html: -------------------------------------------------------------------------------- 1 | {% from "library.j2" import form_button, render_errors %} 2 |
3 | {{- group_chat_form.csrf_token -}} 4 |
5 |

{% trans %}Create new circle group chat{% endtrans %}

6 |

{% trans %}Add a chat to your circle so its members can hold group discussions.{% endtrans %}

7 |

{% trans %}Tip:{% endtrans %} {% trans %}This is only for creating group chats that automatically include all members of the circle. If you want a normal group chat, create it in the Snikket app instead.{% endtrans %}

8 |
9 | {{ group_chat_form.name.label }} 10 | {{ group_chat_form.name }} 11 |
12 |
13 | {%- call form_button("add", group_chat_form.action_save, class="primary") %}{% endcall -%} 14 |
15 |
16 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_create_invite.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% block content %} 3 |

{% trans %}Create invitation{% endtrans %}

4 | {%- include "admin_create_invite_form.html" -%} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_create_invite_form.html: -------------------------------------------------------------------------------- 1 | {% from "library.j2" import form_button, 2 | render_errors, 3 | access_level_description, access_level_icon, 4 | invite_type_description, invite_type_icon 5 | %} 6 |
7 | {{- invite_form.csrf_token -}} 8 |
9 |

{% trans %}Create new invitation{% endtrans %}

10 |

{% trans %}Create a new invitation link to invite more users to your Snikket service by clicking the button below.{% endtrans %}

11 | 12 | 13 |
14 |
{#- -#} 15 | {{ invite_form.type_.label.text }} 16 |

{% trans %}Choose whether this invitation link will allow more than one person to join.{% endtrans %}

17 | 18 | {%- for invite_type in invite_form.type_ -%} 19 |
20 | {{ invite_type }} 25 |
26 | {%- endfor -%} 27 |
28 |
29 | 30 | 31 |
32 |
{#- -#} 33 | {{ invite_form.role.label.text }} 34 |

{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}

35 | {%- for level in invite_form.role -%} 36 |
37 | {{ level }} 42 |
43 | {%- endfor -%} 44 |
45 |
46 | 47 | 48 | 49 |
50 | {{ invite_form.lifetime.label }} 51 |
{{ invite_form.lifetime }}
52 |
53 | 54 | 55 |
56 | {# 57 | NOTE: This is for when/if we ever support multi-group invites. 58 | Also see the NOTE in admin.py 59 | {{ invite_form.circles.label(class="required") }} 60 | {%- for choice in invite_form.circles -%} 61 | {{ choice }}{{ choice.label }} 62 | {%- endfor -%} 63 | #} 64 | {{- invite_form.circles.label -}} 65 |
{{ invite_form.circles }}
66 | {%- call render_errors(invite_form.circles) -%}{%- endcall -%} 67 |
68 | 69 | 70 |
71 | {{ invite_form.note.label }} 72 | {{ invite_form.note }} 73 |
74 | 75 |
76 | {%- call form_button("create_link", invite_form.action_create_invite, class="primary") %}{% endcall -%} 77 |
78 |
79 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_debug_user.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import clipboard_button %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% include "copy-snippet.html" %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans user_name=target_user.localpart %}Debug information for {{ user_name }}{% endtrans %}

9 |

10 |
11 |
{% trans %}Warning{% endtrans %}
12 |

{% trans %}The below dump may contain sensitive information.{% endtrans %}

13 |
14 |
15 |
{% trans %}Raw debug dump{% endtrans %}
16 |

{% call clipboard_button(debug_dump, show_label=True, class="primary") -%} 17 | {% trans %}Copy complete output{% endtrans %} 18 | {%- endcall %}

19 |
20 | {{ debug_dump }}
21 | 
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_delete_circle.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import box, form_button, standard_button %} 3 | {% block content %} 4 |

{% trans circle_name=target_circle.name %}Delete circle {{ circle_name }}{% endtrans %}

5 |
6 |

{% trans %}Delete circle{% endtrans %}

7 | {{ form.csrf_token }} 8 |

{% trans %}Are you sure you want to delete the following circle?{% endtrans %}

9 |
10 |
{% trans %}Name{% endtrans %}
11 |
{{ target_circle.name }}
12 |
13 | {% call box("alert", _("Danger")) %} 14 |

{% trans %}The circle and the corresponding chat will be deleted, permanently and immediately upon pushing the below button. There is no way back!{% endtrans %}

15 | {% endcall %} 16 |
17 | {%- call standard_button("back", url_for(".edit_circle", id_=target_circle.id_), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} 18 | {%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%} 19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_delete_user.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import box, form_button, standard_button %} 3 | {% block content %} 4 |

{% trans user_name=target_user.localpart %}Delete user {{ user_name }}{% endtrans %}

5 |
6 |

{% trans %}Delete user{% endtrans %}

7 | {{ form.csrf_token }} 8 |

{% trans %}Are you sure you want to delete the following user?{% endtrans %}

9 |
10 |
{% trans %}Login name{% endtrans %}
11 |
{{ target_user.localpart }}
12 |
{% trans %}Display name{% endtrans %}
13 |
{{ target_user.display_name }}
14 |
15 | {% call box("alert", _("Danger")) %} 16 |

{% trans %}The user and their data will be deleted irrevocably, permanently and immediately upon pushing the below button. There is no way back!{% endtrans %}

17 | {% endcall %} 18 |
19 | {%- call standard_button("back", url_for(".edit_user", localpart=target_user.localpart), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} 20 | {%- call form_button("delete", form.action_delete, class="primary danger") %}{% endcall -%} 21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_edit_circle.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import form_button, standard_button, value_or_hint, custom_form_button, clipboard_button, icon, render_user with context %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% include "copy-snippet.html" %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans circle_name=(target_circle | circle_name) %}Edit circle {{ circle_name }}{% endtrans %}

9 |
10 | {{- form.csrf_token -}} 11 | {%- if target_circle.id_ == "default" -%} 12 | {#- -#} 13 |
14 |
{% trans %}This is your main circle{% endtrans %}
15 |

{% trans %}This circle is managed automatically and cannot be removed or renamed.{% endtrans %}

16 |
17 | {%- else -%} 18 |
19 |

{% trans %}Circle information{% endtrans %}

20 |
21 | {{ form.name.label }} 22 | {{ form.name }} 23 |
24 |
25 | {%- call standard_button("back", url_for(".circles"), class="tertiary") -%} 26 | {% trans %}Return to circle list{% endtrans %} 27 | {%- endcall -%} 28 | {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} 29 |
30 |

{% trans %}Delete circle{% endtrans %}

31 |

{% trans %}Deleting a circle does not delete any users in the circle.{% endtrans %}

32 |
33 | {%- call standard_button("delete", url_for(".delete_circle", id_=target_circle.id_), class="secondary danger") %}{% trans %}Delete circle{% endtrans %}{% endcall -%} 34 |
35 |
36 | {%- endif -%} 37 | 38 |

{% trans %}Group chats{% endtrans %}

39 |

{% trans %}These group chats will be available to all members of the circle.{% endtrans %}

40 | 41 | {%- if circle_chats -%} 42 |
43 | 44 | 45 | 46 | 47 | 48 | {%- for chat in circle_chats -%} 49 | 50 | 51 | 56 | 57 | {%- endfor -%} 58 | 59 |
{% trans %}Name{% endtrans %}{% trans %}Actions{% endtrans %}
{% call value_or_hint(chat.name) %}{% endcall %} 52 | {%- call custom_form_button("delete", form.action_remove_group_chat.name, chat.id_, class="primary danger", slim=True) -%} 53 | {% trans name=chat.name %}Delete group chat '{{ name }}'{% endtrans %} 54 | {%- endcall -%} 55 |
60 | {%- else -%} 61 |

{% trans %}This circle currently has no group chats.{% endtrans %}

62 | {%- endif -%} 63 | {%- call standard_button("add", url_for(".edit_circle_add_chat", id_=target_circle.id_), class="secondary") -%} 64 | {% trans %}Add group chat{% endtrans %} 65 | {%- endcall -%} 66 | 67 |

{% trans %}Circle members{% endtrans %}

68 |

{% trans %}All members of the circle will see each other in their contact list.{% endtrans %}

69 | 70 | {%- if circle_members -%} 71 |
72 | 73 | 74 | 75 | 76 | 77 | {%- for localpart, member in circle_members -%} 78 | 79 | 87 | 92 | 93 | {%- endfor -%} 94 | 95 |
{% trans %}Login name{% endtrans %}{% trans %}Actions{% endtrans %}
80 | {%- if member -%} 81 | {%- call render_user(member) -%}{%- endcall -%} 82 | {%- else -%} 83 | {{ localpart }} 84 | ({% trans %}deleted{% endtrans %}) 85 | {%- endif -%} 86 | 88 | {%- call custom_form_button("remove_user", form.action_remove_user.name, member.localpart, class="primary danger", slim=True) -%} 89 | {% trans username=member.localpart %}Remove user {{ username }} from circle{% endtrans %} 90 | {%- endcall -%} 91 |
96 | {%- else -%} 97 |

{% trans %}This circle currently has no members.{% endtrans %}

98 | {%- endif -%} 99 |

{% trans %}Invite more members{% endtrans %}

100 | {%- if form.user_to_add.choices -%} 101 |
102 |

{% trans %}Add existing user{% endtrans %}

103 |
104 | {{- form.user_to_add.label -}} 105 |
{{ form.user_to_add }}
106 |
107 |
108 | {%- call form_button("add_user", form.action_add_user, class="primary") %}{% endcall -%} 109 |
110 |
111 | {%- else -%} 112 |
113 |
{% trans %}All users added{% endtrans %}
114 |

{% trans %}All users on this service are already in this circle.{% endtrans %}

115 |
116 | {%- endif -%} 117 |
118 | {%- include "admin_create_invite_form.html" -%} 119 | {% endblock %} 120 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_edit_invite.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import showuri, form_button, standard_button, extract_circle_name, invite_type_name, invite_type_description %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% include "copy-snippet.html" %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans %}View invitation{% endtrans %}

9 |
10 | {{ form.csrf_token }} 11 |
12 |
13 |
{% trans %}Valid until{% endtrans %}
14 |
{{ invite.expires | format_date }}
15 |
16 |
{% call showuri(invite.landing_page, id_="link-field") %}{% trans %}Invitation to Snikket{% endtrans %}{% endcall %}
17 |
{% trans %}Invitation type{% endtrans %}
18 | {% set invite_type = invite.reusable and "group" or "account" %} 19 |
{% call invite_type_name(invite_type) %}{% endcall %}
20 | {%- set ngroups = invite.group_ids | length -%} 21 | {%- if ngroups > 1 -%} 22 | {#- not supported via the web UI, but we should still display it properly -#} 23 |
{% trans %}Circles{% endtrans %}
24 |

{% trans %}Users joining via this invitation will be added to the following circles:{% endtrans %}

    25 | {%- for group_id in invite.group_ids -%} 26 |
  • {%- call extract_circle_name(circle_map, group_id) -%}{%- endcall -%}
  • 27 | {%- endfor -%} 28 |
29 | {%- else -%} 30 |
{% trans %}Circle{% endtrans %}
31 |
32 | {%- if ngroups == 1 -%} 33 | {%- set group_id = invite.group_ids[0] -%} 34 | {%- call extract_circle_name(circle_map, group_id) -%}{%- endcall -%} 35 | {%- else -%} 36 | {% trans %}The user will not be added to any circle and will have no contacts.{% endtrans %} 37 | {%- endif -%} 38 |
39 | {%- endif -%} 40 | {%- if invite.type_.value == "roster" -%} 41 |
{% trans %}Contact{% endtrans %}
42 |
{% trans peer_jid=invite.jid %}The user will get added as contact of {{ peer_jid }}.{% endtrans %}
43 | {%- endif -%} 44 |
{% trans %}Created{% endtrans %}
45 |
{{ invite.created_at | format_date }}
46 |
47 |
48 | {%- call standard_button("back", url_for(".invitations"), class="tertiary") %} 49 | {% trans %}Return to invitation list{% endtrans %} 50 | {%- endcall %} 51 | {%- call form_button("remove_link", form.action_revoke, class="primary danger") %}{% endcall -%} 52 |
53 |
54 |
55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_edit_user.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import box, form_button, standard_button, icon, access_level_description, access_level_icon %} 3 | {% block content %} 4 |

{% trans user_name=target_user.localpart %}Edit user {{ user_name }}{% endtrans %}

5 |
{{ form.csrf_token }}
6 | {% if target_user.deletion_request %} 7 |
8 |
{% trans %}This user account is pending deletion{% endtrans %}
9 |

{% trans date=target_user.deletion_request.deleted_at | format_datetime %}The owner of the account sent a deletion request on {{ date }} using their app.{% endtrans %} 10 |

{% trans time=(target_user.deletion_request.pending_until - now())|format_timedelta %}The account has been locked, and will be automatically deleted permanently in {{ time }}.{% endtrans %}

11 | 12 |

{% trans %}If this was a mistake, you can cancel the deletion and restore the account.{% endtrans %}

13 | 14 | {%- call form_button("restore_from_trash", form.action_restore, class="secondary") %}{% endcall %} 15 |
16 | {% elif not target_user.enabled %} 17 |
18 |
{% trans %}This user account is locked{% endtrans %}
19 |

{% trans %}The user will not be able to log in to their account until it is unlocked again.{% endtrans %}

20 | 21 | {%- call form_button("lock_open", form.action_enable, class="secondary") %}{% endcall %} 22 |
23 | {% endif %} 24 | 25 |

{% trans %}Edit user{% endtrans %}

26 | 27 |
28 | {{ form.localpart.label }} 29 | {{ form.localpart(readonly="readonly") }} 30 |

{% trans %}The login name cannot be changed.{% endtrans %}

31 |
32 | 33 |
34 | {{ form.display_name.label }} 35 | {{ form.display_name }} 36 |
37 |

{% trans %}Access Level{% endtrans %}

38 |

{% trans %}The access level of a user determines what interactions are allowed for them on your Snikket service.{% endtrans %}

39 |
40 |
{#- -#} 41 | {{ form.role.label.text }} 42 | {%- for level in form.role -%} 43 |
44 | {{ level }} 49 |
50 | {%- endfor -%} 51 |
52 |
53 |
54 | {%- call standard_button("back", url_for(".users"), class="tertiary") -%} 55 | {%- trans -%}Return to user list{%- endtrans -%} 56 | {%- endcall -%} 57 | {%- call standard_button("delete", url_for(".delete_user", localpart=target_user.localpart), class="secondary") -%} 58 | {%- trans -%}Delete user{%- endtrans -%} 59 | {%- endcall -%} 60 | {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} 61 |
62 |
63 |

{% trans %}Further actions{% endtrans %}

64 |
65 |

{% trans %}Reset password{% endtrans %}

66 | {{ form.csrf_token }} 67 |

68 | {% trans %}If the user has lost their password, you can use the button below to create a special link which allows to change the password of the account, once.{% endtrans %} 69 |

70 |
71 | {%- call form_button("passwd", form.action_create_reset, class="secondary") -%}{%- endcall -%} 72 |
73 |

{% trans %}Debug information{% endtrans %}

74 |

75 | {% trans %}In some cases, extended information about the user account and the connected devices is necessary to troubleshoot issues. The button below reveals this (sensitive) information.{% endtrans %} 76 |

77 |
78 | {%- call standard_button("bug_report", url_for(".debug_user", localpart=target_user.localpart), class="secondary") -%} 79 | {%- trans -%}Show debug information{%- endtrans -%} 80 | {%- endcall -%} 81 |
82 |
83 | {% endblock %} 84 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_home.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% set body_id = "home" %} 3 | {% block content %} 4 |

{% trans %}Welcome to the admin panel!{% endtrans %}

5 |

{% trans user_name=user_info.display_name %}At your service, {{ user_name }}.{% endtrans %}

6 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_invites.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import action_button, icon, clipboard_button, share_button, form_button, custom_form_button, extract_circle_name, invite_type_name, invite_type_description %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% include "copy-snippet.html" %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans %}Manage invitations{% endtrans %}

9 | {%- include "admin_create_invite_form.html" -%} 10 |

{% trans %}Pending invitations{% endtrans %}

11 | {% if invites %} 12 |
13 | {{- form.csrf_token -}} 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for invite in invites %} 30 | {% set invite_type = invite.reusable and "group" or "account" %} 31 | 32 | 33 | 42 | 43 | 44 | 58 | 59 | {% endfor %} 60 | 61 |
{% trans %}Type{% endtrans %}{% trans %}Circle{% endtrans %}{% trans %}Expires{% endtrans %}{% trans %}Comment{% endtrans %}{% trans %}Actions{% endtrans %}
{% call invite_type_name(invite_type) %}{% endcall %} 34 | {#- -#} 35 |
    36 | {%- for group_id in invite.group_ids -%} 37 |
  • {%- call extract_circle_name(circle_map, group_id) -%}{%- endcall -%}
  • 38 | {%- endfor -%} 39 |
40 | {#- -#} 41 |
{{ (invite.expires - now) | format_timedelta(add_direction=True) }}{% if invite.note is not none %}{{ invite.note }}{% endif %} 45 | {%- call action_button("more", url_for(".edit_invite", id_=invite.id_), class="secondary") -%} 46 | {% trans %}Show invite details{% endtrans %} 47 | {%- endcall -%} 48 | {%- call clipboard_button(invite.landing_page, class="primary") -%} 49 | {% trans %}Copy invite link to clipboard{% endtrans %} 50 | {%- endcall -%} 51 | {%- call share_button("Invitation to Snikket", invite.landing_page, class="primary") -%} 52 | {% trans %}Share invitation link{% endtrans %} 53 | {%- endcall -%} 54 | {%- call custom_form_button("remove_link", form.action_revoke.name, invite.id_, class="secondary danger", slim=True) -%} 55 | {% trans %}Delete invitation{% endtrans %} 56 | {%- endcall -%} 57 |
62 | {% else %} 63 |

{% trans %}Currently, there are no pending invitations.{% endtrans %}

64 | {% endif %} 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_reset_user_password.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import showuri, standard_button, custom_form_button %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% include "copy-snippet.html" %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans %}Password reset{% endtrans %}

9 |
10 | {{- form.csrf_token -}} 11 |
12 |

{% trans user_name=localpart %}Password reset link for {{ user_name }}{% endtrans %}

13 |

{% trans %}The following link will allow the user to reset their password on their device, once.{% endtrans %}

14 |
15 |
{% trans %}Valid until{% endtrans %}
16 |
{{ reset_link.expires | format_date }}
17 |
18 |
{% call showuri(reset_link.landing_page, id_="link-field") %}Reset your Snikket password{% endcall %}
19 | 20 |
21 | {%- call custom_form_button("remove_link", form.action_revoke.name, reset_link.id_, class="secondary danger") -%} 22 | {% trans %}Destroy link{% endtrans %} 23 | {%- endcall -%} 24 | {%- call standard_button("back", url_for(".edit_user", localpart=localpart), class="primary") -%} 25 | {% trans %}Back{% endtrans %} 26 | {%- endcall -%} 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_system.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import form_button %} 3 | {% block content %} 4 |

{% trans %}Manage system{% endtrans %}

5 | {% if show_metrics %} 6 |

{% trans %}Overall system status{% endtrans %}

7 |
8 |
9 |
{% trans %}System load (5 minute average){% endtrans %}
10 |
11 | {%- if metrics.load5 -%} 12 | {{ metrics.load5 }} 13 | {%- else -%} 14 | {% trans %}unknown{% endtrans %} 15 | {%- endif -%} 16 |
17 |
{% trans %}Memory use{% endtrans %}
18 |
19 | {%- if metrics.mem_total and metrics.mem_available -%} 20 | {% trans percentage_global=((1 - (metrics.mem_available / metrics.mem_total)) | format_percent), percentage_snikket=((((metrics.prosody_rss | default(0)) + (metrics.portal_rss | default(0))) / metrics.mem_total) | format_percent), mem_available=(metrics.mem_total | format_bytes) %}{{ percentage_global }} of {{ mem_available }}. Of that, Snikket uses {{ percentage_snikket }}.{% endtrans %} 21 | {%- else -%} 22 | {% trans %}unknown{% endtrans %} 23 | {%- endif -%} 24 |
25 |
26 |
27 |

{% trans %}Web portal status{% endtrans %}

28 |
29 |
30 |
{% trans %}Version{% endtrans %}
31 |
{{ version }} {% trans %}View all versions{% endtrans %}
32 |
{% trans %}Average CPU use{% endtrans %}
33 |
34 | {%- if metrics.portal_cpu -%} 35 | {{ metrics.portal_cpu | format_percent }} 36 | {%- else -%} 37 | {% trans %}unknown{% endtrans %} 38 | {%- endif -%} 39 |
40 |
{% trans %}Current memory use{% endtrans %}
41 |
42 | {%- if metrics.portal_rss -%} 43 | {{ metrics.portal_rss | format_bytes }} 44 | {%- else -%} 45 | {% trans %}unknown{% endtrans %} 46 | {%- endif -%} 47 |
48 |
49 |
50 |

{% trans %}Snikket server status{% endtrans %}

51 |
52 |
53 |
{% trans %}Version{% endtrans %}
54 |
{{ prosody_version }} {% trans %}View all versions{% endtrans %}
55 |
{% trans %}Average CPU use{% endtrans %}
56 |
57 | {%- if metrics.prosody_cpu -%} 58 | {{ metrics.prosody_cpu | format_percent }} 59 | {%- else -%} 60 | {% trans %}unknown{% endtrans %} 61 | {%- endif -%} 62 |
63 |
{% trans %}Current memory use{% endtrans %}
64 |
65 | {%- if metrics.prosody_rss -%} 66 | {{ metrics.prosody_rss | format_bytes }} 67 | {%- else -%} 68 | {% trans %}unknown{% endtrans %} 69 | {%- endif -%} 70 |
71 |
{% trans %}Storage used by shared files{% endtrans %}
72 |
73 | {%- if metrics.prosody_uploads | default(None) is not none -%} 74 | {{ metrics.prosody_uploads | format_bytes }} 75 | {%- else -%} 76 | {% trans %}unknown{% endtrans %} 77 | {%- endif -%} 78 |
79 |
{% trans %}Active users{% endtrans %}
80 |
81 |
    82 | {%- if metrics.prosody_devices | default(None) is not none -%} 83 |
  • {% trans %}Connected now:{% endtrans %} {{ metrics.prosody_devices }}
  • 84 | {%- else -%} 85 |
  • {% trans %}unknown{% endtrans %}
  • 86 | {%- endif -%} 87 | {%- if metrics.users | default(None) is not none -%} 88 |
  • {% trans %}Past 24 hours:{% endtrans %} {{ metrics.users.active_1d }}
  • 89 |
  • {% trans %}Past 7 days:{% endtrans %} {{ metrics.users.active_7d }}
  • 90 |
  • {% trans %}Past 30 days:{% endtrans %} {{ metrics.users.active_30d }}
  • 91 | {%- endif -%} 92 |
93 |
94 |
95 |
96 | {% endif %} 97 |

{% trans %}Broadcast message{% endtrans %}

98 |
{{ form.csrf_token }}
99 |

{% trans %}This form allows you to send a message to all users currently online on your Snikket server. Use it wisely.{% endtrans %}

100 |
101 | {{ form.text.label }} 102 | {{ form.text }} 103 |
104 |
105 | {{ form.online_only }}{{ form.online_only.label }} 106 |
107 |
108 | {%- call form_button("send", form.action_send_preview, class="primary") -%}{%- endcall -%} 109 | {%- call form_button("broadcast", form.action_post_all, class="secondary accent") -%}{%- endcall -%} 110 |
111 |
112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /snikket_web/templates/admin_users.html: -------------------------------------------------------------------------------- 1 | {% extends "admin_app.html" %} 2 | {% from "library.j2" import action_button, avatar, icon, render_user, value_or_hint, custom_form_button with context %} 3 | {% block content %} 4 |

{% trans %}Manage users{% endtrans %}

5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for user in users %} 15 | 16 | 19 | {% if user.enabled %} 20 | 21 | {% elif user.deletion_request %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 31 | 32 | {% endfor %} 33 | 34 |
{% trans %}User{% endtrans %}{% trans %}Last active{% endtrans %}{% trans %}Actions{% endtrans %}
17 | {%- call render_user(user) -%}{%- endcall -%} 18 | {{ user.last_active | format_last_activity }}{% trans %}Deleted{% endtrans %}{% trans %}Locked{% endtrans %} 27 | {%- call action_button("edit", url_for(".edit_user", localpart=user.localpart), class="primary") -%} 28 | {% trans user_name=user.localpart %}Edit user {{ user_name }}{% endtrans %} 29 | {%- endcall -%} 30 |
35 | {%- include "admin_create_invite_form.html" -%} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /snikket_web/templates/app.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block head_lead %} 4 | {% trans %}Snikket Web Portal{% endtrans %} 5 | {% endblock %} 6 | {% block topbar_right %} 7 | {{- super() -}} 8 | {% call standard_button("logout", url_for("user.logout"), class="tertiary slimmify") %}{% trans %}Log out{% endtrans %}{% endcall %} 9 | {%- endblock %} 10 | -------------------------------------------------------------------------------- /snikket_web/templates/backend_error.html: -------------------------------------------------------------------------------- 1 | {% extends "exception.html" %} 2 | {% block box -%} 3 |
{% trans %}Internal error{% endtrans %}
4 |

{% trans %}The web portal was not able to communicate with the backend.{% endtrans %}

5 |

{% trans %}Please try again later and/or inform your Snikket service admin.{% endtrans %}

6 | {%- endblock %} 7 | -------------------------------------------------------------------------------- /snikket_web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head_lead %}{% endblock %} 6 | 7 | {% block style %} 8 | 9 | 10 | {% endblock %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block body %}{% endblock %} 20 | 21 | -------------------------------------------------------------------------------- /snikket_web/templates/copy-snippet.html: -------------------------------------------------------------------------------- 1 | 177 | 178 | -------------------------------------------------------------------------------- /snikket_web/templates/exception.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% block head_lead -%} 3 | {% trans %}Internal error{% endtrans %} 4 | {%- endblock %} 5 | {% block content %} 6 |
7 | {%- block box %}{% endblock -%} 8 |
 9 | GURU MEDITATION
10 | {% if traceback %}
11 | {{- traceback -}}
12 | {% else %}
13 | {{- exception_short -}}
14 | {% endif %}
15 | error_id={{ error_id }}
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /snikket_web/templates/generic_http_error.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block content -%} 4 |
5 |
{{ name }}
6 |

{{ description }}.

7 |
8 | {%- call standard_button("back", url_for("index"), class="primary") -%} 9 | {% trans %}Go back to the main page{% endtrans %} 10 | {%- endcall -%} 11 |
12 |
13 | {%- endblock %} 14 | -------------------------------------------------------------------------------- /snikket_web/templates/internal_error.html: -------------------------------------------------------------------------------- 1 | {% extends "exception.html" %} 2 | {% block box -%} 3 |
{% trans %}Internal error{% endtrans %}
4 |

{% trans %}The web portal encountered an internal error.{% endtrans %}

5 |

{% trans %}Please try again later and/or inform your Snikket service admin.{% endtrans %}

6 | {%- endblock %} 7 | -------------------------------------------------------------------------------- /snikket_web/templates/invite.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block style %} 4 | {{ super() }} 5 | 6 | {% endblock %} 7 | {% block body %} 8 |
{% block content %}{% endblock %}
9 | {%- include "_footer.html" -%} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_invalid.html: -------------------------------------------------------------------------------- 1 | {% extends "invite.html" %} 2 | {% set body_id = "invite" %} 3 | {% block content %} 4 |
5 |

{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }}{% endtrans %}

6 |
{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by Snikket{% endtrans %}
7 |
8 |
{% trans %}Invite expired{% endtrans %}
9 |

{% trans %}Sorry, it looks like this invitation link has expired!{% endtrans %}

10 |
11 | Sad person holding empty box 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_register.html: -------------------------------------------------------------------------------- 1 | {% extends "invite.html" %} 2 | {% set body_id = "invite" %} 3 | {% from "library.j2" import form_button, render_errors %} 4 | {% block head_lead %} 5 | {% trans site_name=config["SITE_NAME"] %}Register on {{ site_name }} | Snikket{% endtrans %} 6 | {% endblock %} 7 | {% block content %} 8 |
9 |

{% trans site_name=config["SITE_NAME"] %}Register on {{ site_name }}{% endtrans %}

10 |
{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by Snikket{% endtrans %}
11 |

{% trans site_name=config["SITE_NAME"] %}{{ site_name }} is using Snikket - a secure, privacy-friendly chat app.{% endtrans %}

12 |

{% trans %}Create an account{% endtrans %}

13 |

{% trans %}Creating an account will allow to communicate with other people using the Snikket app or compatible software. If you already have the app installed, we recommend that you continue the account creation process inside the app by clicking on the button below:{% endtrans %}

14 |

{% trans %}App already installed?{% endtrans %}

15 | {%- call standard_button("exit_to_app", invite.xmpp_uri, class="secondary") -%} 16 | {% trans %}Open the app{% endtrans %} 17 | {%- endcall -%} 18 |

{% trans %}This button works only if you have the app installed already!{% endtrans %}

19 |

{% trans %}Create an account online{% endtrans %}

20 |

{% trans %}If you plan to use a legacy XMPP client, you can register an account online and enter your credentials into any XMPP-compatible software.{% endtrans %}

21 |
22 | {{- form.csrf_token -}} 23 | {%- call render_errors(form) %}{% endcall -%} 24 |
25 | {{ form.localpart.label }} 26 |
{{ form.localpart(class="localpart-magic") }}@{{ config["SNIKKET_DOMAIN"] }}
27 |

{% trans %}Choose a username, this will become the first part of your new chat address.{% endtrans %}

28 |
29 |
30 | {{ form.password.label }} 31 | {{ form.password(autocomplete="new-password") }} 32 |

{% trans %}Enter a secure password that you do not use anywhere else.{% endtrans %}

33 |
34 |
35 | {{ form.password_confirm.label }} 36 | {{ form.password_confirm(autocomplete="new-password") }} 37 |
38 |
39 | {%- call form_button("done", form.action_register, class="primary") -%}{%- endcall -%} 40 |
41 |
42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% from "library.j2" import standard_button, render_errors %} 3 | {% block style %} 4 | {{ super() }} 5 | 6 | {% endblock %} 7 | {% block head_lead %} 8 | {{ super() }} 9 | {% trans %}Reset your password | Snikket{% endtrans %} 10 | {% endblock %} 11 | {% block content %} 12 |
13 | {{- form.csrf_token -}} 14 |

{% trans %}Reset your password online{% endtrans %}

15 |

{% trans %}To reset your password online, fill out the fields below and confirm using the button.{% endtrans %}

16 | {%- call render_errors(form) %}{% endcall -%} 17 |
18 | {{ form.password.label }} 19 | {{ form.password(autocomplete="new-password") }} 20 |
21 |
22 | {{ form.password_confirm.label }} 23 | {{ form.password_confirm(autocomplete="new-password") }} 24 |
25 |
26 | {%- call form_button("passwd", form.action_reset, class="primary") -%}{%- endcall -%} 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_reset_success.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block head_lead %} 4 | {{ super() }} 5 | {% trans %}Password reset successful | Snikket{% endtrans %} 6 | {% endblock %} 7 | {% block content %} 8 |

{% trans %}Password reset successful{% endtrans %}

9 |
10 |
{% trans %}Your password has been changed{% endtrans %}
11 |

{% trans %}You can now log in using your new password.{% endtrans %}

12 |

{% trans %}You can now safely close this page.{% endtrans %}

13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_reset_view.html: -------------------------------------------------------------------------------- 1 | {% extends "unauth.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block style %} 4 | {{ super() }} 5 | 6 | {% endblock %} 7 | {% block head_lead %} 8 | {{ super() }} 9 | {% trans %}Reset your password | Snikket{% endtrans %} 10 | 11 | 12 | {% endblock %} 13 | {% block content %} 14 |

{% trans %}Reset your password{% endtrans %}

15 |

{% trans account_jid=account_jid %}This page allows you to reset the password of your account, {{ account_jid }}, once.{% endtrans %}

16 |
17 |

{% trans %}Using the app{% endtrans %}

18 |

{% trans %}To reset your password using the Snikket App, tap the button below.{% endtrans %}

19 |
20 | {%- call standard_button("exit_to_app", invite.xmpp_uri, class="secondary") -%} 21 | {% trans %}Open the app{% endtrans %} 22 | {%- endcall -%} 23 |
24 | 25 |

{% trans %}Alternatively, you can scan the below code with the Snikket App using the Scan button at the top.{% endtrans %}

26 |

{% trans %}Your camera will turn on. Point it at the square code below until it is within the highlighted square on your screen, and wait until the app recognises it.{% endtrans %}

27 |

{% trans %}You will then be prompted to enter a new password for your account.{% endtrans %}

28 |
29 |

{% trans %}Alternatives{% endtrans %}

30 |

{% trans reset_url=url_for(".reset", id_=invite_id) %}You can also reset your password online if the above button or scanning the QR code does not work for you.{% endtrans %}

31 |
32 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_success.html: -------------------------------------------------------------------------------- 1 | {% extends "invite.html" %} 2 | {% set body_id = "invite" %} 3 | {% from "library.j2" import form_button, clipboard_button, render_errors %} 4 | {% block head_lead %} 5 | {% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }} | Snikket{% endtrans %} 6 | {%- include "copy-snippet.html" -%} 7 | {% endblock %} 8 | {% block content %} 9 |
10 |

{% trans site_name=config["SITE_NAME"] %}Successfully registered on {{ site_name }}{% endtrans %}

11 |
{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by Snikket{% endtrans %}
12 |

{% trans site_name=config["SITE_NAME"], jid=jid %}Congratulations! You successfully registered on {{ site_name }} as {{ jid }}.{% endtrans %}

13 | 14 | {%- call clipboard_button(jid, show_label=True) -%} 15 | {% trans %}Copy address{% endtrans %} 16 | {%- endcall -%} 17 |

{% trans %}You can now set up your legacy XMPP client with the above address and the password you chose during registration.{% endtrans %}

18 |

{% trans login_url=url_for('main.login') %}You can now safely close this page, or log in to the web portal to manage your account.{% endtrans %}

19 | 20 | {% if migration_success %} 21 |

{% trans %}Import successful{% endtrans %}

22 |

{% trans %}Congratulations! Your account data has been successfully imported.{% endtrans %}

23 | {% endif %} 24 | 25 | {% if form %} 26 |

{% trans %}Moving to Snikket?{% endtrans %}

27 |

{% trans %}If you are moving from a different Snikket instance or another XMPP-compatible service, you may optionally import the data (contacts, profile information, etc.) from your previous account. When you have exported the data from your previous account, upload it using the form below.{% endtrans %}

28 | 29 |
30 |

{% trans %}Upload account data{% endtrans %}

31 | {{ form.csrf_token }} 32 | {% call render_errors(form) %}{% endcall %} 33 |
34 | {{ form.account_data_file.label }} 35 | {{ form.account_data_file(accept="application/xml", 36 | data_maxsize=max_import_size, 37 | data_warning_header=import_too_big_warning_header, 38 | data_maxsize_warning=import_too_big_warning) }} 39 |
40 |
41 | {%- call form_button("upload", form.action_import, class="secondary") %}{% endcall -%} 42 |
43 | 58 |
59 | {% endif %} 60 |
61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /snikket_web/templates/invite_view.html: -------------------------------------------------------------------------------- 1 | {% extends "invite.html" %} 2 | {% set onload = "onload();" %} 3 | {% set body_id = "invite" %} 4 | {% from "library.j2" import action_button %} 5 | {% block head_lead %} 6 | {% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }} | Snikket{% endtrans %} 7 | 8 | 9 | 10 | {% endblock %} 11 | {% block content %} 12 |
13 |

{% trans site_name=config["SITE_NAME"] %}Invite to {{ site_name }}{% endtrans %}

14 |
{% trans logo_url=url_for("static", filename="img/snikket-logo-text.svg") %}Powered by Snikket{% endtrans %}
15 | {%- if invite.inviter -%} 16 |

{% trans site_name=config["SITE_NAME"], inviter_name=invite.inviter %}You have been invited to chat with {{ inviter_name }} using Snikket, a secure, privacy-friendly chat app on {{ site_name }}.{% endtrans %}

17 | {%- else -%} 18 |

{% trans site_name=config["SITE_NAME"] %}You have been invited to chat on {{ site_name }} using Snikket, a secure, privacy-friendly chat app.{% endtrans %}

19 | {%- endif -%} 20 | 21 | {%- if config["TOS_URI"] and config["PRIVACY_URI"] -%} 22 |

23 | {% trans site_name=config["SITE_NAME"], tos_uri=config["TOS_URI"], privacy_uri=config["PRIVACY_URI"] %}By continuing, you agree to the Terms of Service and Privacy Policy.{% endtrans %} 24 |

25 | {%- endif -%} 26 | 27 |

{% trans %}Get started{% endtrans %}

28 | {%- if apple_store_url -%} 29 |

{% trans %}Install the Snikket App on your Android or iOS device.{% endtrans %}

30 | {%- else -%} 31 |

{% trans ios_info_url="https://snikket.org/faq/#is-there-an-ios-app" %}Install the Snikket App on your Android device (iOS coming soon!).{% endtrans %}

32 | {%- endif -%} 33 |
34 |
    35 |
  • {% trans %}Get it on Google Play{% endtrans %}
  • 36 | {%- if apple_store_url -%} 37 |
  • {% trans %}Download on the App Store{% endtrans %}
  • 38 | {%- endif -%} 39 |
  • {% trans %}Get it on F-Droid{% endtrans %}
  • 40 |
41 | {%- call standard_button("qrcode", "#qr-modal", class="primary", onclick="open_modal(this); return false;") -%} 42 | {% trans %}Send to mobile device{% endtrans %} 43 | {%- endcall -%} 44 |
45 |

{% trans %}After installation the app should automatically open and prompt you to create an account. If not, simply click the button below.{% endtrans %}

46 |

{% trans %}App already installed?{% endtrans %}

47 | {%- call standard_button("exit_to_app", invite.xmpp_uri, class="secondary") -%} 48 | {% trans %}Open the app{% endtrans %} 49 | {%- endcall -%} 50 |

{% trans %}This button works only if you have the app installed already!{% endtrans %}

51 | 52 |

{% trans %}Alternatives{% endtrans %}

53 |

{% trans register_url=url_for(".register", id_=invite_id) %}You can connect to Snikket using any XMPP-compatible software. If the button above does not work with your app, you may need to register an account manually.{% endtrans %}

54 |
55 | 73 | {%- if apple_store_url -%} 74 | 101 | {%- endif -%} 102 | 129 | 145 | {% endblock %} 146 | -------------------------------------------------------------------------------- /snikket_web/templates/library.j2: -------------------------------------------------------------------------------- 1 | {% macro box(type, title, slim=False, caller=None) %} 2 | 3 | {% endmacro %} 4 | 5 | {% macro avatar(from_, hash, char=None, caller=None) -%} 6 | {%- if hash -%} 7 |
8 | {%- else -%} 9 |
10 | {%- endif -%} 11 | {%- endmacro %} 12 | 13 | {% macro render_user(user, caller=None) -%} 14 |
15 |
16 | {%- call avatar(user.localpart+"@"+config["SNIKKET_DOMAIN"], user.avatar_info[0].hash if user.avatar_info | length > 0 else None ) %}{% endcall -%} 17 | {%- if user.has_admin_role -%} 18 |
19 | {% call icon("admin") %}{% trans %} (Administrator){% endtrans %}{% endcall %} 20 |
21 | {%- elif user.has_restricted_role -%} 22 |
23 | {% call icon("lock") %}{% trans %} (Restricted){% endtrans %}{% endcall %} 24 |
25 | {%- endif -%} 26 |
27 | 33 |
34 | {%- endmacro -%} 35 | 36 | {% macro showuri(uri, caller=None, id_=None) %} 37 | {%- if uri is none -%} 38 | 39 | {%- else -%} 40 |
41 |
42 | {% call clipboard_button(uri, show_label=True) %}{% trans %}Copy link{% endtrans %}{% endcall %} 43 | {% call share_button(caller() if caller is not none else None, uri, show_label=True) %}{% trans %}Share{% endtrans %}{% endcall %} 44 |
45 | {%- endif -%} 46 | {% endmacro %} 47 | 48 | {% macro icon(name, caller=None) -%} 49 | {%- set alt = "" if caller is none else caller() -%} 50 | {%- if alt %}{{ alt }}{% endif %} 51 | {%- endmacro %} 52 | 53 | {% macro standard_button(icon_name, href, caller=None, class=None, onclick=None) -%} 54 | {%- set label = caller() -%} 55 | {% call icon(icon_name) %}{% endcall %}{{ label }} 56 | {%- endmacro %} 57 | 58 | {% macro form_button(icon_name, button_obj, caller=None, class=None) -%} 59 | 60 | {%- endmacro %} 61 | 62 | {% macro custom_form_button(icon_name, name, value, caller=None, slim=False, class=None) -%} 63 | {%- set text = caller() -%} 64 | 79 | {%- endmacro %} 80 | 81 | {% macro action_button(icon_name, href, caller=None, class=None, onclick=None) -%} 82 | {%- set a11y = caller() -%} 83 | {% call icon(icon_name) %}{% endcall %} 84 | {%- endmacro %} 85 | 86 | {% macro clipboard_button(data, show_label=False, caller=None, class=None) -%} 87 | {%- set label = caller() -%} 88 | 96 | {%- call icon("copy") %}{% endcall -%} 97 | {%- if show_label %} 98 | {{ label }} 99 | {% endif -%} 100 | 101 | {%- endmacro %} 102 | 103 | {% macro share_button(title, url, show_label=False, caller=None, class=None) -%} 104 | {%- set label = caller() -%} 105 | 119 | {%- endmacro %} 120 | 121 | {% macro render_errors(field, caller=None) -%} 122 | {%- set error_list = field.errors if field.errors is not mapping else (field.errors.values() | flatten | list) -%} 123 | {%- if error_list -%} 124 |
{#- -#} 125 |
{% trans %}Invalid input{% endtrans %}
126 | {%- if error_list | length == 1 -%} 127 |

{{ error_list[0] }}

128 | {%- else -%} 129 |
    130 | {%- for error in error_list -%} 131 |
  • {{ error }}
  • 132 | {%- endfor -%} 133 |
134 | {%- endif -%} 135 |
136 | {%- endif -%} 137 | {%- endmacro %} 138 | 139 | {% macro value_or_hint(v, caller=None) %} 140 | {%- if v is not none -%} 141 | {{- v -}} 142 | {%- else -%} 143 | — 144 | {%- endif -%} 145 | {% endmacro %} 146 | 147 | {% macro extract_circle_name(circle_map, id, caller=None) %} 148 | {%- set circle_info = circle_map[id] -%} 149 | {%- if circle_info -%} 150 | {{ circle_info | circle_name }} 151 | {%- else -%} 152 | {% trans %}deleted{% endtrans %} 153 | {%- endif -%} 154 | {% endmacro %} 155 | 156 | {%- macro invite_type_name(invite_type, caller=None) -%} 157 | {%- if invite_type == "account" -%} 158 | {% trans %}Individual{% endtrans %} 159 | {%- else -%} 160 | {% trans %}Group{% endtrans %} 161 | {%- endif -%} 162 | {%- endmacro -%} 163 | 164 | {% macro access_level_description(role, caller=None) %} 165 | {%- if role == "prosody:restricted" -%} 166 | {% trans %}Limited users can interact with users on the same Snikket service and be members of circles.{% endtrans %} 167 | {%- elif role == "prosody:registered" -%} 168 | {% trans %}Like limited users and can also interact with users on other Snikket services.{% endtrans %} 169 | {%- elif role == "prosody:admin" -%} 170 | {% trans %}Like normal users and can access the admin panel in the web portal.{% endtrans %} 171 | {%- endif -%} 172 | {% endmacro %} 173 | 174 | {% macro access_level_icon(role, caller=None) %} 175 | {%- if role == "prosody:restricted" -%} 176 | {% call icon("lock") %}{% endcall %} 177 | {%- elif role == "prosody:admin" -%} 178 | {% call icon("admin") %}{% endcall %} 179 | {%- endif -%} 180 | {% endmacro %} 181 | 182 | {% macro invite_type_description(invite_type, caller=None) %} 183 | {%- if invite_type == "account" -%} 184 | {% trans %}Invite a single person (invitation link can only be used once).{% endtrans %} 185 | {%- elif invite_type == "group" -%} 186 | {% trans %}Invite a group of people (invitation link can be used multiple times).{% endtrans %} 187 | {%- endif -%} 188 | {% endmacro %} 189 | 190 | {% macro invite_type_icon(invite_type, caller=None) %} 191 | {%- if invite_type == "account" -%} 192 | {% call icon("person") %}{% endcall %} 193 | {%- elif invite_type == "group" -%} 194 | {% call icon("people") %}{% endcall %} 195 | {%- endif -%} 196 | {% endmacro %} 197 | -------------------------------------------------------------------------------- /snikket_web/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "library.j2" import box, form_button, render_errors %} 3 | {% set body_id = "login" %} 4 | {% block head_lead %} 5 | {{ _("Snikket Login") }} 6 | {% endblock %} 7 | {% block style %} 8 | 9 | {{ super() }} 10 | {% endblock %} 11 | {% block body %} 12 |
13 |

{{ config["SITE_NAME"] }}

14 |

{{ _("Enter your Snikket address and password to manage your account.") }}

15 |
16 | {{ form.csrf_token }} 17 | {% call render_errors(form) %}{% endcall %} 18 | 22 |
23 | {{ form.address.label(class="a11y-only") }} 24 | {{ form.address(placeholder=form.address.label.text) }} 25 |
26 |
27 | {{ form.password.label(class="a11y-only") }} 28 | {{ form.password(placeholder=form.password.label.text) }} 29 |
30 |
31 | {%- call form_button("login", form.action_signin, class="primary") -%}{% endcall -%} 32 |
33 |
34 | 50 |
51 | {%- include "_footer.html" -%} 52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /snikket_web/templates/policies.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "library.j2" import standard_button %} 3 | {% block head_lead %} 4 | {% trans %}Policies{% endtrans %} - {{ config["SITE_NAME"] }} 5 | {% endblock %} 6 | {% block body %} 7 |
8 |
9 |

{{ config["SITE_NAME"] }}

10 |

{% trans %}Policies{% endtrans %}

11 | 12 | {% if config["TOS_URI"] or config["PRIVACY_URI"] -%} 13 |

{% trans %}Use of this service is subject to the following policies:{% endtrans %}

14 | 22 | {%- else -%} 23 |

{% trans %}Please contact the administrator of this instance if you have questions about policies.{% endtrans %}

24 | {% endif -%} 25 | 26 |

{% trans url="https://snikket.org/app/privacy/" %}Use of the Snikket apps is subject to the Snikket Apps Privacy Policy.{% endtrans %}

27 | 28 | {%- if config["ABUSE_EMAIL"] %} 29 |

{% trans email=config["ABUSE_EMAIL"], domain=config["SNIKKET_DOMAIN"] %}To report policy violations or other abuse from this service, please send an email to {{email}}. Specify the domain name of this instance ({{domain}}) and include details of the incident(s).{% endtrans %}

30 | {%- endif %} 31 | 32 |

33 | {%- call standard_button("back", url_for("index"), class="primary") -%} 34 | {% trans %}Back to the main page{% endtrans %} 35 | {%- endcall -%} 36 |

37 |
38 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /snikket_web/templates/security.txt: -------------------------------------------------------------------------------- 1 | # {{ config["SNIKKET_DOMAIN"] }} is running open-source software 2 | # from the Snikket project: https://snikket.org/ 3 | 4 | {% if config["SECURITY_EMAIL"] -%} 5 | # Security issues related to this service should be addressed to the 6 | # following security contact: 7 | Contact: mailto:{{ config["SECURITY_EMAIL"] }} 8 | {% else -%} 9 | # This service does not have a public security contact. You might find 10 | # more information about the service at the following link: 11 | Contact: https://{{ config["SNIKKET_DOMAIN"] }}/policies/ 12 | {%- endif %} 13 | 14 | # Please report software defects to the project developers, per the 15 | # instructions at the following link: 16 | Contact: https://snikket.org/security/ 17 | -------------------------------------------------------------------------------- /snikket_web/templates/unauth.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from "library.j2" import box, form_button %} 3 | {% block body %} 4 |
5 |
{{ config["SITE_NAME"] }}
6 | {% block topbar_left %}{% endblock %} 7 |
8 | {% block topbar_right %}{% endblock %} 9 |
10 |
11 | {#- -#} 12 |
13 | {%- for category, message in get_flashed_messages(True) -%} 14 | 22 | {%- endfor -%} 23 |
24 | {#- -#} 25 |
{% block content %}{% endblock %}
26 | {#- -#} 27 |
28 | {#- -#} 29 |
30 | {%- include "_footer.html" -%} 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /snikket_web/templates/user_home.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import clipboard_button, standard_button, avatar with context %} 3 | {% set body_id = "home" %} 4 | {% block head_lead %} 5 | {{ super() }} 6 | {% include "copy-snippet.html" %} 7 | {% endblock %} 8 | {% block content %} 9 | {% if user_info.is_admin and metrics.users and metrics.users.active_1d <= 1 %} 10 | 15 | {% endif %} 16 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /snikket_web/templates/user_logout.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import standard_button, form_button %} 3 | {% block content %} 4 |
5 |

{% trans %}Sign out of the Snikket Web Portal{% endtrans %}

6 |

{% trans %}Click below to log yourself out of the web portal. This does not affect any other connected devices.{% endtrans %}

7 | {{ form.csrf_token }} 8 |
9 | {%- call standard_button("back", url_for("user.index"), class="tertiary") -%} 10 | {% trans %}Back{% endtrans %} 11 | {%- endcall -%} 12 | {%- call form_button("logout", form.action_signout, class="primary") %}{% endcall -%} 13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /snikket_web/templates/user_manage_data.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} 3 | {% block content %} 4 |

{% trans %}Manage your data{% endtrans %}

5 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /snikket_web/templates/user_passwd.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import standard_button, custom_form_button, render_errors %} 3 | {% block content %} 4 |
5 |

{% trans %}Change your password{% endtrans %}

6 |

{% trans %}To change your password, you need to provide the current password as well as the new one. To reduce the chance of typos, we ask for your new password twice.{% endtrans %}

7 | {{ form.csrf_token }} 8 | {%- call render_errors(form) -%} 9 | {%- endcall -%} 10 |
11 | {{ form.current_password.label(class="required") }} 12 | {{ form.current_password(class=("has-error" if form.current_password.name in form.errors else ""), autocomplete="current-password") }} 13 |
14 |
15 | {{ form.new_password.label(class="required") }} 16 | {{ form.new_password(autocomplete="new-password") }} 17 |
18 |
19 | {{ form.new_password_confirm.label(class="required") }} 20 | {{ form.new_password_confirm(class=("has-error" if form.new_password_confirm.name in form.errors else ""), autocomplete="new-password") }} 21 |
22 |
23 |
{% trans %}Warning{% endtrans %}
24 |

{% trans %}After changing your password, you will have to enter the new password on all of your devices.{% endtrans %}

25 |
26 |
27 | {%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} 28 | {%- call custom_form_button("passwd", "", "", class="primary") -%} 29 | {% trans %}Change password{% endtrans %} 30 | {%- endcall -%} 31 |
32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /snikket_web/templates/user_profile.html: -------------------------------------------------------------------------------- 1 | {% extends "app.html" %} 2 | {% from "library.j2" import standard_button, form_button, render_errors, avatar with context %} 3 | {% block content %} 4 |

{% trans %}Update your profile{% endtrans %}

5 |
6 |

{% trans %}Profile{% endtrans %}

7 | {{ form.csrf_token }} 8 | {% call render_errors(form) %}{% endcall %} 9 |
10 | {{ form.nickname.label }} 11 | {{ form.nickname(placeholder=user_info.username) }} 12 |
13 |
14 | {{ form.avatar.label }} 15 |
16 | {%- call avatar(user_info.address, user_info.avatar_hash ) %}{% endcall -%} 17 | {{ form.avatar(accept="image/png", 18 | data_maxsize=max_avatar_size, 19 | data_warning_header=avatar_too_big_warning_header, 20 | data_maxsize_warning=avatar_too_big_warning) }} 21 |
22 |
23 |

{% trans %}Visibility{% endtrans %}

24 |

{% trans %}This section allows you to control who can see your profile information, like avatar and nickname.{% endtrans %}

25 |
26 |
{#- -#} 27 | {{ form.profile_access_model.label.text }} 28 | {{- form.profile_access_model -}} 29 |
30 |
31 |
32 | {%- call standard_button("back", url_for('.index'), class="tertiary") %}{% trans %}Back{% endtrans %}{% endcall -%} 33 | {%- call form_button("done", form.action_save, class="primary") %}{% endcall -%} 34 |
35 | 54 |
55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /snikket_web/translations/da/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/da/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/de/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/de/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/en_GB/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/en_GB/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/es/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/es/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/es_MX/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/es_MX/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/fr/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/fr/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/id/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/id/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/it/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/it/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/pl/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/pl/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/ru/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/ru/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/sv/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/sv/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/uk/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/uk/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/translations/zh_Hans_CN/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snikket-im/snikket-web-portal/af13a3cc47aa0eba1fb73c5ff08c8a9b422551d1/snikket_web/translations/zh_Hans_CN/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /snikket_web/user.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | import urllib 4 | 5 | import quart.flask_patch 6 | from quart import ( 7 | Blueprint, 8 | Response, 9 | render_template, 10 | request, 11 | redirect, 12 | url_for, 13 | flash, 14 | current_app, 15 | ) 16 | import werkzeug.exceptions 17 | 18 | import wtforms 19 | 20 | from flask_babel import lazy_gettext as _l, _ 21 | 22 | from .infra import client, BaseForm 23 | 24 | bp = Blueprint('user', __name__) 25 | 26 | 27 | class ChangePasswordForm(BaseForm): 28 | current_password = wtforms.PasswordField( 29 | _l("Current password"), 30 | validators=[wtforms.validators.InputRequired()] 31 | ) 32 | 33 | new_password = wtforms.PasswordField( 34 | _l("New password"), 35 | validators=[ 36 | wtforms.validators.InputRequired(), 37 | wtforms.validators.Length(min=10), 38 | ] 39 | ) 40 | 41 | new_password_confirm = wtforms.PasswordField( 42 | _l("Confirm new password"), 43 | validators=[ 44 | wtforms.validators.InputRequired(), 45 | wtforms.validators.EqualTo( 46 | "new_password", 47 | _l("The new passwords must match.") 48 | ), 49 | wtforms.validators.Length(min=10), 50 | ] 51 | ) 52 | 53 | 54 | class LogoutForm(BaseForm): 55 | action_signout = wtforms.SubmitField( 56 | _l("Sign out"), 57 | ) 58 | 59 | 60 | _ACCESS_MODEL_CHOICES = [ 61 | ("whitelist", _l("Nobody")), 62 | ("presence", _l("Friends only")), 63 | ("open", _l("Everyone")), 64 | ] 65 | 66 | 67 | class ProfileForm(BaseForm): 68 | nickname = wtforms.StringField( 69 | _l("Display name"), 70 | ) 71 | 72 | avatar = wtforms.FileField( 73 | _l("Avatar") 74 | ) 75 | 76 | profile_access_model = wtforms.RadioField( 77 | _l("Profile visibility"), 78 | choices=_ACCESS_MODEL_CHOICES, 79 | ) 80 | 81 | action_save = wtforms.SubmitField( 82 | _l("Update profile"), 83 | ) 84 | 85 | 86 | class ImportAccountDataForm(BaseForm): 87 | account_data_file = wtforms.FileField( 88 | _l("Account data") 89 | ) 90 | 91 | action_upload = wtforms.SubmitField( 92 | _l("Upload"), 93 | ) 94 | 95 | 96 | @bp.route("/") 97 | @client.require_session() 98 | async def index() -> str: 99 | user_info = await client.get_user_info() 100 | try: 101 | metrics = await client.get_system_metrics() 102 | except (werkzeug.exceptions.Unauthorized, werkzeug.exceptions.Forbidden): 103 | metrics = {} 104 | return await render_template( 105 | "user_home.html", 106 | user_info=user_info, 107 | metrics=metrics, 108 | ) 109 | 110 | 111 | @bp.route('/passwd', methods=["GET", "POST"]) 112 | @client.require_session() 113 | async def change_pw() -> typing.Union[str, werkzeug.Response]: 114 | form = ChangePasswordForm() 115 | if form.validate_on_submit(): 116 | try: 117 | await client.change_password( 118 | form.current_password.data, 119 | form.new_password.data, 120 | ) 121 | except (werkzeug.exceptions.Unauthorized, 122 | werkzeug.exceptions.Forbidden): 123 | # server refused current password, set an appropriate error 124 | form.current_password.errors.append( 125 | _("Incorrect password."), 126 | ) 127 | else: 128 | await flash( 129 | _("Password changed"), 130 | "success", 131 | ) 132 | return redirect(url_for("user.change_pw")) 133 | 134 | return await render_template("user_passwd.html", form=form) 135 | 136 | 137 | EAVATARTOOBIG = _l( 138 | "The chosen avatar is too big. To be able to upload larger " 139 | "avatars, please use the app." 140 | ) 141 | 142 | 143 | @bp.route("/profile", methods=["GET", "POST"]) 144 | @client.require_session() 145 | async def profile() -> typing.Union[str, werkzeug.Response]: 146 | max_avatar_size = current_app.config["MAX_AVATAR_SIZE"] 147 | 148 | form = ProfileForm() 149 | if request.method != "POST": 150 | user_info = await client.get_user_info() 151 | # TODO: find a better way to determine the access model, e.g. by 152 | # taking the first access model which is defined in [nickname, avatar, 153 | # vcard] or by taking the most open one.- 154 | profile_access_model = await client.guess_profile_access_model() 155 | form.nickname.data = user_info.get("nickname", "") 156 | form.profile_access_model.data = profile_access_model 157 | 158 | if form.validate_on_submit(): 159 | user_info = await client.get_user_info() 160 | 161 | ok = True 162 | file_info = (await request.files).get(form.avatar.name) 163 | if file_info is not None: 164 | mimetype = file_info.mimetype 165 | data = file_info.stream.read() 166 | if len(data) > max_avatar_size: 167 | form.avatar.errors.append(EAVATARTOOBIG) 168 | ok = False 169 | elif len(data) > 0: 170 | await client.set_user_avatar(data, mimetype) 171 | 172 | if ok: 173 | if user_info.get("nickname") != form.nickname.data: 174 | await client.set_user_nickname(form.nickname.data) 175 | 176 | access_model = form.profile_access_model.data 177 | await asyncio.gather( 178 | client.set_avatar_access_model(access_model), 179 | client.set_vcard_access_model(access_model), 180 | client.set_nickname_access_model(access_model), 181 | ) 182 | 183 | await flash( 184 | _("Profile updated"), 185 | "success", 186 | ) 187 | return redirect(url_for(".profile")) 188 | 189 | return await render_template("user_profile.html", 190 | form=form, 191 | max_avatar_size=max_avatar_size, 192 | avatar_too_big_warning_header=_l("Error"), 193 | avatar_too_big_warning=EAVATARTOOBIG) 194 | 195 | 196 | class DataExportForm(BaseForm): 197 | action_export = wtforms.SubmitField( 198 | _l("Export") 199 | ) 200 | 201 | 202 | @bp.route("/manage_data", methods=["GET", "POST"]) 203 | @client.require_session() 204 | async def manage_data() -> typing.Union[str, quart.Response]: 205 | form = DataExportForm() 206 | 207 | if form.validate_on_submit(): 208 | user_info = await client.get_user_info() 209 | # The UTF-8 version of the filename needs to be percent-encoded 210 | encoded_address = urllib.parse.quote( 211 | user_info["address"].encode(encoding='utf-8', errors='strict') 212 | ) 213 | account_data = await client.export_account_data() 214 | if account_data is None: 215 | await flash( 216 | _("You currently have no account data to export."), 217 | "alert" 218 | ) 219 | else: 220 | return Response(account_data, 221 | mimetype="application/xml", 222 | headers={ 223 | # We provide the UTF-8 filename, but the ASCII 224 | # one will be used as a fallback for legacy 225 | # browsers (RFC 5987) 226 | "Content-Disposition": ( 227 | 'attachment; filename="account-data.xml"; ' 228 | 'filename*="UTF-8\'\'account-data-{}.xml"' 229 | ).format(encoded_address) 230 | }) 231 | return await render_template("user_manage_data.html", 232 | form=form, 233 | ) 234 | 235 | 236 | @bp.route("/logout", methods=["GET", "POST"]) 237 | @client.require_session() 238 | async def logout() -> typing.Union[werkzeug.Response, str]: 239 | form = LogoutForm() 240 | if form.validate_on_submit(): 241 | await client.logout() 242 | # No flashing here because we don’t collect flashes in the login page 243 | # and it’d be weird. 244 | # await flash( 245 | # _("Logged out"), 246 | # "success", 247 | # ) 248 | return redirect(url_for("main.index")) 249 | 250 | return await render_template("user_logout.html", form=form) 251 | -------------------------------------------------------------------------------- /tools/icons.list: -------------------------------------------------------------------------------- 1 | action/account_circle:profile 2 | action/bug_report:bug_report 3 | action/done:done 4 | action/delete:delete 5 | action/logout:logout 6 | action/login:login 7 | action/exit_to_app:exit_to_app 8 | action/lock:lock 9 | action/lock_open:lock_open 10 | action/restore_from_trash:restore_from_trash 11 | communication/import_export:import_export 12 | communication/qr_code:qrcode 13 | communication/vpn_key:passwd 14 | communication/rss_feed:broadcast 15 | content/add_circle_outline:add 16 | content/add_link:create_link 17 | content/remove_circle_outline:remove 18 | content/content_copy:copy 19 | content/link_off:remove_link 20 | content/send:send 21 | file/file_download:download 22 | file/file_upload:upload 23 | file/folder:folder 24 | navigation/arrow_back:back 25 | navigation/arrow_forward:forward 26 | navigation/cancel:cancel 27 | navigation/more_vert:more 28 | social/groups:groups 29 | social/people:people 30 | social/person:person 31 | social/group_add:create_group 32 | social/person_add:add_user 33 | social/person_remove:remove_user 34 | navigation/close:close 35 | image/edit:edit 36 | action/admin_panel_settings:admin 37 | content/link:link 38 | content/insights:insights 39 | social/share:share 40 | -------------------------------------------------------------------------------- /tools/import-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | # usage: import-icons.sh ROOT ICONLIST FLAVOR SVGOUT 4 | # 5 | # positional arguments: 6 | # 7 | # ROOT path to the checkout of https://github.com/google/material-design-icons 8 | # ICONLIST path to the icons.list file in the snikket-web-portal repository 9 | # FLAVOR one of '', 'round', 'sharp', 'outlined', 'twoshade' 10 | # SVGOUT path to the newly created SVG file 11 | root="$1/src" 12 | iconlist_file="${2-tools/icons.list}" 13 | flavor="${3-round}" 14 | output_file="${4-snikket_web/static/img/icons.svg}" 15 | 16 | printf '' 19 | 20 | IFS=$'\n' 21 | while read -r icondef; do 22 | path="$(cut -d':' -f1 <<<"$icondef")" 23 | name="$(cut -d':' -f2 <<<"$icondef")" 24 | src_path="$path/materialicons$flavor" 25 | if [ ! -d "$root/$src_path" ]; then 26 | printf 'warning: %q not found in flavor %q, falling back to default\n' "$path" "$flavor" >&2 27 | src_path="$path/materialicons" 28 | fi 29 | src_svg="$src_path/24px.svg" 30 | if [ ! -f "$root/$src_svg" ]; then 31 | printf 'error: failed to find source file for %q: %s: does not exist\n' "$path" "$src_svg" >&2 32 | fi 33 | printf '\n' "$src_svg" >> "$output_file" 34 | printf '\n' "$name" >> "$output_file" 35 | xpath -q -e '/svg/*' "$root/$src_svg" >> "$output_file" 36 | printf '\n' >> "$output_file" 37 | 38 | printf '

\n' "$name" 39 | done < "$iconlist_file" 40 | printf '\n' >> "$output_file" 41 | --------------------------------------------------------------------------------