├── src └── lektorium │ ├── __init__.py │ ├── __main__.py │ ├── utils.py │ ├── repo │ ├── local │ │ ├── __init__.py │ │ ├── lektor.py │ │ ├── templates.py │ │ ├── objects.py │ │ ├── repo.py │ │ └── server.py │ ├── __init__.py │ ├── interface.py │ └── memory.py │ ├── client │ ├── components │ │ ├── Alert.vue │ │ ├── Error.vue │ │ ├── Callback.vue │ │ ├── App.vue │ │ ├── Profile.vue │ │ ├── ThemeSelect.vue │ │ └── NavBar.vue │ ├── scripts │ │ ├── main.js │ │ └── authService.js │ ├── images │ │ └── loading.svg │ └── public │ │ └── index.html │ ├── proxy.py │ ├── jwt.py │ ├── aws.py │ ├── app.py │ ├── auth0.py │ └── schema.py ├── .dockerignore ├── pytest.ini ├── .flake8 ├── containers ├── server │ ├── entrypoint.sh │ ├── Dockerfile.alpine │ └── Dockerfile.ubuntu ├── lektor │ └── Dockerfile ├── nginx-proxy │ ├── Dockerfile │ └── nginx.conf └── lectern │ └── Dockerfile ├── certs └── traefik-config.yml ├── DEVELOPMENT.md ├── tests ├── test_app.py ├── test_server.py ├── test_gitlab_storage.py ├── test_gitlab_real.py ├── conftest.py ├── test_local.py ├── test_jwt.py ├── test_repo.py ├── test_storage.py ├── test_auth0_client.py ├── test_permissions.py ├── test_aws.py ├── test_gitlab.py └── test_graphql.py ├── .github └── workflows │ └── pythonapp.yml ├── .circleci └── config.yml ├── LICENSE ├── .gitlab-ci.yml ├── setup.py ├── .gitignore ├── DEPLOYMENT.md ├── CONTRIBUTING.md ├── README.md └── tasks.py /src/lektorium/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /env 2 | /src 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = "tests" 3 | -------------------------------------------------------------------------------- /src/lektorium/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .app import main 4 | 5 | 6 | main(*sys.argv[1:]) 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git, __pycache__, venv, .eggs, build 3 | filename = *.py 4 | max-line-length = 120 5 | max-doc-length = 79 6 | select = E,W,F,C 7 | import-order-style = spoqa 8 | inline-quotes = ' 9 | -------------------------------------------------------------------------------- /src/lektorium/utils.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import contextlib 3 | 4 | 5 | def closer(manager): 6 | closer = contextlib.ExitStack() 7 | result = closer.enter_context(manager) 8 | atexit.register(closer.close) 9 | return result 10 | -------------------------------------------------------------------------------- /src/lektorium/repo/local/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .lektor import FakeLektor, LocalLektor 3 | from .repo import Repo 4 | from .server import AsyncDockerServer, AsyncDockerServerLectern, AsyncLocalServer, FakeServer 5 | from .storage import FileStorage, GitlabStorage, GitStorage 6 | -------------------------------------------------------------------------------- /src/lektorium/client/components/Alert.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ message }} 4 | 5 | 6 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/lektorium/repo/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .interface import ( 3 | DuplicateEditSession, 4 | ExceptionBase, 5 | InvalidSessionState, 6 | SessionNotFound, 7 | ) 8 | from .local import Repo as LocalRepo 9 | from .memory import SITES 10 | from .memory import Repo as ListRepo 11 | -------------------------------------------------------------------------------- /containers/server/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -xe 3 | ssh-keyscan "$GIT_SERVER" >>/root/.ssh/known_hosts 4 | [ -n "$GIT_MAIL" ] || (>&2 echo "empty GIT_MAIL" && false) 5 | [ -n "$GIT_USER" ] || (>&2 echo "empty GIT_USER" && false) 6 | git config --global user.email "$GIT_MAIL" 7 | git config --global user.name "$GIT_USER" 8 | exec /env/bin/python -m lektorium "$@" 9 | -------------------------------------------------------------------------------- /containers/lektor/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE 2 | FROM $BASE_IMAGE 3 | RUN apk -U add --no-cache python3 python3-dev openssl-dev gcc musl-dev libffi-dev cargo \ 4 | && python3 -m venv env \ 5 | && PATH="/env/bin:$PATH" pip install --no-cache "lektor==3.2.0" pytz "markupsafe==2.0.1" "Flask==1.1.4" \ 6 | && rm -rf /root/.cache 7 | ENV PATH="/env/bin:$PATH" 8 | ENTRYPOINT ["lektor"] 9 | -------------------------------------------------------------------------------- /certs/traefik-config.yml: -------------------------------------------------------------------------------- 1 | tls: 2 | stores: 3 | default: 4 | defaultCertificate: 5 | certFile: /etc/certs/cert.pem 6 | keyFile: /etc/certs/key.pem 7 | certificates: 8 | - certFile: /etc/certs/cert.pem 9 | keyFile: /etc/certs/key.pem 10 | stores: 11 | - default 12 | options: 13 | default: 14 | minVersion: VersionTLS12 15 | mintls13: 16 | minVersion: VersionTLS13 17 | -------------------------------------------------------------------------------- /containers/nginx-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE 2 | FROM $BASE_IMAGE 3 | RUN apk -U add nginx \ 4 | && rm /var/cache/apk/APKINDEX* \ 5 | && mkdir -p /run/nginx /logs \ 6 | EXPOSE 80 7 | WORKDIR / 8 | ARG SERVER_NAME 9 | ENV SERVER_NAME="${SERVER_NAME}" 10 | COPY nginx.conf /nginx.conf 11 | RUN sed -i'' "s/ENV_SERVER_NAME/$SERVER_NAME/" nginx.conf 12 | CMD nginx -c /nginx.conf -p / -g "daemon off; error_log /dev/stdout debug; pid /dev/null;" 13 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # TDD with real gitlab 2 | Module `test_gitlab_real.py` contains helpers to do TDD with real gitlab. 3 | To use it you need to run test suite with `LEKTORIUM_GITLAB_TEST` environment 4 | varibale provided. This variable whould be formatted as `[=:]=`. 5 | Possible keys are: 6 | * scheme= 7 | * host= 8 | * config= 9 | * token= 10 | -------------------------------------------------------------------------------- /containers/lectern/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE 2 | FROM $BASE_IMAGE 3 | ARG PIP_EXTRA_INDEX_URL 4 | ARG LECTERN_PKG 5 | RUN apk -U add --no-cache git python3 python3-dev openssl-dev gcc musl-dev libffi-dev cargo npm \ 6 | && python3 -m venv env \ 7 | && source ./env/bin/activate \ 8 | && pip install --upgrade pip && pip install --upgrade wheel setuptools \ 9 | && PIP_EXTRA_INDEX_URL=$PIP_EXTRA_INDEX_URL pip install "$LECTERN_PKG" \ 10 | && rm -rf /root/.cache 11 | ENV PATH="/env/bin:$PATH" 12 | ENTRYPOINT ["lectern-ui"] 13 | -------------------------------------------------------------------------------- /containers/server/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE 2 | FROM $BASE_IMAGE 3 | RUN apk -U add python3 python3-dev openssh-client git git-lfs gcc musl-dev libffi-dev openssl-dev pkg-config cargo \ 4 | && python3 -m venv env 5 | ADD lektorium*whl / 6 | ADD key /root/.ssh/id_rsa 7 | ADD entrypoint.sh / 8 | RUN chmod 700 /root/.ssh \ 9 | && chmod 600 /root/.ssh/id_rsa \ 10 | && PATH="/env/bin:$PATH" pip install --no-cache setuptools-rust \ 11 | && PATH="/env/bin:$PATH" pip install --no-cache *whl 12 | ENV PATH="/env/bin:$PATH" 13 | VOLUME /sessions 14 | ENTRYPOINT ["/entrypoint.sh"] 15 | -------------------------------------------------------------------------------- /src/lektorium/client/components/Error.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{msg}} 4 | 5 | × 6 | 7 | 8 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /src/lektorium/client/components/Callback.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 18 | 19 | 33 | -------------------------------------------------------------------------------- /src/lektorium/client/components/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /src/lektorium/client/scripts/main.js: -------------------------------------------------------------------------------- 1 | let router = new VueRouter({ 2 | mode: 'history', 3 | routes: [{ 4 | path: '/', 5 | component: httpVueLoader('/components/App.vue'), 6 | children: [ 7 | {path: '', component: httpVueLoader('/components/ControlPanel.vue')}, 8 | {path: 'profile', component: httpVueLoader('/components/Profile.vue')}, 9 | {path: 'callback', component: httpVueLoader('/components/Callback.vue')}, 10 | ], 11 | }], 12 | }); 13 | 14 | router.beforeEach(((router, to, from, next) => { 15 | if (router.app.$auth === undefined || to.path === "/callback" || router.app.$auth.isAuthenticated()) { 16 | return next(); 17 | } 18 | router.app.$auth.login({ target: to.fullPath }); 19 | }).bind(undefined, router)); 20 | 21 | if (_.get(lektoriumAuth0Config, 'domain')) { 22 | authServiceInstall(Vue); 23 | }; 24 | 25 | new Vue({el: '#app', router}); 26 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from lektorium import app 6 | 7 | 8 | def test_app(): 9 | with patch('lektorium.repo.LocalRepo') as local_repo: 10 | app.create_app(app.RepoType.LOCAL, '', '') 11 | local_repo.assert_called_once() 12 | (storage, *_), kwargs = local_repo.call_args 13 | assert kwargs == dict(sessions_root=None) 14 | assert hasattr(storage, 'config') 15 | 16 | 17 | @pytest.mark.skip(reason='breaks many other tests') 18 | async def test_index(aiohttp_client, loop): 19 | client = await aiohttp_client(app.create_app(auth='test.auth0.com')) 20 | assert (await client.get('/')).status == 200 21 | assert (await client.get('/scripts/main.js')).status == 200 22 | assert (await client.get('/components/App.vue')).status == 200 23 | response = await client.get('/auth0-config') 24 | assert response.status == 200 25 | assert 'lektoriumAuth0Config' in await response.text() 26 | assert 'test.auth0.com' in await response.text() 27 | -------------------------------------------------------------------------------- /src/lektorium/repo/local/lektor.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import os 3 | import subprocess 4 | 5 | 6 | class Lektor(metaclass=abc.ABCMeta): 7 | @classmethod 8 | @abc.abstractmethod 9 | def quickstart(cls, name, owner, folder): 10 | raise NotImplementedError() 11 | 12 | 13 | class FakeLektor(Lektor): 14 | @classmethod 15 | def quickstart(cls, name, owner, folder): 16 | folder.mkdir(parents=True) 17 | (folder / 'fake-lektor.file').touch() 18 | 19 | 20 | class LocalLektor(Lektor): 21 | @classmethod 22 | def quickstart(cls, name, owner, folder): 23 | proc = subprocess.Popen( 24 | 'lektor quickstart', 25 | shell=True, 26 | stdin=subprocess.PIPE, 27 | stdout=subprocess.DEVNULL, 28 | ) 29 | proc.communicate(input=os.linesep.join(( 30 | name, 31 | owner, 32 | str(folder), 33 | 'n', 34 | 'Y', 35 | '', 36 | )).encode()) 37 | if proc.wait() != 0: 38 | raise RuntimeError() 39 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.8 20 | - name: pip caching 21 | uses: actions/cache@v1 22 | with: 23 | path: ~/.cache/pip 24 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 25 | restore-keys: | 26 | ${{ runner.os }}-pip- 27 | - name: dependencies 28 | run: | 29 | git config --global user.email "github-action@example.com" 30 | git config --global user.name "GitHub Action" 31 | python -m pip install --upgrade pip 32 | pip install -e .[dev] 33 | pip install spherical-dev[dev] 34 | - name: isort and flake8 35 | run: | 36 | inv isort -c 37 | inv flake 38 | - name: tests 39 | run: | 40 | inv test 41 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | codecov: codecov/codecov@1.0.5 4 | jobs: 5 | build: 6 | docker: 7 | - image: circleci/python:3.8.2-buster 8 | 9 | working_directory: ~/repo 10 | 11 | steps: 12 | - checkout 13 | 14 | - run: 15 | name: set up env 16 | command: | 17 | git config --global user.email "circle@ci.example.com" 18 | git config --global user.name "Circle Ci" 19 | sudo apt-get update 20 | sudo apt-get -y install git-lfs 21 | python3 -m venv venv 22 | . venv/bin/activate 23 | pip install -Ue .[dev] 24 | 25 | - run: 26 | name: check style 27 | command: | 28 | . venv/bin/activate 29 | flake8 src tests 30 | 31 | - run: 32 | name: run tests 33 | command: | 34 | . venv/bin/activate 35 | python -m pytest --cov-report=xml:coverage.xml --cov=`pwd` 36 | 37 | - codecov/upload: 38 | file: coverage.xml 39 | token: 154a6f93-3f50-40bb-b3e9-d3c18fa3c626 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Spherical Origins GmbH & Co. KG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lektorium/client/components/Profile.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | {{ profile.name }} 13 | {{ profile.email }} 14 | 15 | 16 | 17 | 18 | 19 | {{ JSON.stringify(profile, null, 4) }} 20 | 21 | 22 | 23 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - sync 3 | - deploy 4 | 5 | lektorium-deploy: 6 | stage: deploy 7 | variables: 8 | LC_ALL: C.UTF-8 9 | LANG: C.UTF-8 10 | GIT_STRATEGY: clone 11 | GIT_DEPTH: 0 12 | script: 13 | - cat /etc/alpine-release 14 | - apk add --update python3 git docker-cli bash 15 | - apk add --update py3-pip || true 16 | - python3 -m venv ./venv 17 | - . ./venv/bin/activate 18 | - git tag && git status && git describe 19 | - pip install 'spherical-dev[dev]>=0.2.2,<0.3.0' wheel 20 | - cp ${LEKTORIUM_DEPLOY_CONFIG} invoke.yaml 21 | - inv deploy 22 | - docker ps -a 23 | - deactivate 24 | only: 25 | - master 26 | 27 | manual_sync: 28 | stage: sync 29 | image: alpine:latest 30 | script: 31 | - apk add --no-cache git rsync openssh-client 32 | 33 | - repo_url=$(echo "${CI_REPOSITORY_URL}" | sed 's/.*@//') 34 | 35 | - git remote set-url origin https://oauth2:${CI_ACCESS_TOKEN}@$repo_url 36 | 37 | - git config --global user.name "GitLab CI" 38 | - git config --global user.email "ci@${CI_SERVER_HOST}" 39 | 40 | - git clone https://github.com/sphericalpm/lektorium.git /tmp/lektorium 41 | 42 | - rsync -av --delete --exclude='.git' /tmp/lektorium/ ./ 43 | 44 | - git diff 45 | 46 | - git add . 47 | - git commit -am 'Sync with upstream repo' 48 | - git push origin HEAD:${CI_COMMIT_REF_NAME} 49 | 50 | only: 51 | - web 52 | -------------------------------------------------------------------------------- /src/lektorium/proxy.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import aiohttp.web 5 | 6 | 7 | async def handler(path, request): 8 | logging.debug('New connection received') 9 | try: 10 | ws = aiohttp.web.WebSocketResponse() 11 | reader, writer = await asyncio.open_unix_connection(path) 12 | await ws.prepare(request) 13 | await streamer(ws, reader, writer) 14 | return ws 15 | finally: 16 | logging.debug('Connection closed') 17 | 18 | 19 | async def streamer( 20 | ws, 21 | reader: asyncio.StreamReader, 22 | writer: asyncio.StreamWriter, 23 | ): 24 | create_task = asyncio.get_event_loop().create_task 25 | other = create_task(tcp2ws(ws, reader)) 26 | 27 | def ws_close(*args, **kwargs): 28 | create_task(ws.close()) 29 | other.add_done_callback(ws_close) 30 | 31 | try: 32 | await ws2tcp(ws, writer) 33 | finally: 34 | other.cancel() 35 | await other 36 | 37 | 38 | async def ws2tcp( 39 | ws, 40 | writer: asyncio.StreamWriter, 41 | ): 42 | try: 43 | async for data in ws: 44 | if data.type == aiohttp.WSMsgType.BINARY: 45 | writer.write(data.data) 46 | else: 47 | raise RuntimeError(f'{data.type} not supported') 48 | finally: 49 | await writer.drain() 50 | writer.close() 51 | await writer.wait_closed() 52 | 53 | 54 | async def tcp2ws( 55 | ws, 56 | reader: asyncio.StreamReader, 57 | ): 58 | while True: 59 | data = await reader.read(1024) 60 | if not data: 61 | break 62 | await ws.send_bytes(data) 63 | -------------------------------------------------------------------------------- /containers/server/Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | FROM ubuntu:bionic 2 | RUN ( \ 3 | [ "$(. /etc/os-release; echo $ID)" != "ubuntu" ] || { \ 4 | grep universe$ /etc/apt/sources.list || \ 5 | head -1 /etc/apt/sources.list | \ 6 | sed 's/ [a-zA-Z]*$/ universe/' >>/etc/apt/sources.list; \ 7 | } \ 8 | ) \ 9 | && apt-get update \ 10 | && apt-get -y install \ 11 | git \ 12 | git-lfs \ 13 | libffi-dev \ 14 | locales \ 15 | openssh-client \ 16 | python3 \ 17 | python3-venv \ 18 | \ 19 | gcc \ 20 | python3-dev \ 21 | libdpkg-perl \ 22 | libssl-dev \ 23 | curl \ 24 | pkg-config \ 25 | && locale-gen en_US.UTF-8 \ 26 | && python3 -m venv env \ 27 | && (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y) \ 28 | && rm -rf \ 29 | /var/lib/apt/* \ 30 | /var/log/apt/* \ 31 | /var/cache/apt/* \ 32 | /usr/lib/python3.6/__pycache__ 33 | RUN git lfs install --force 34 | ADD lektorium*whl / 35 | ADD key /root/.ssh/id_rsa 36 | ADD entrypoint.sh / 37 | RUN chmod 700 /root/.ssh \ 38 | && chmod 600 /root/.ssh/id_rsa \ 39 | && \ 40 | LC_ALL=en_US.UTF-8 \ 41 | PATH="$HOME/.cargo/bin:/env/bin:$PATH" \ 42 | pip install --no-cache setuptools-rust \ 43 | && \ 44 | LC_ALL=en_US.UTF-8 \ 45 | PATH="$HOME/.cargo/bin:/env/bin:$PATH" \ 46 | pip install --no-cache *whl \ 47 | && rm -rf \ 48 | /usr/lib/python3.6/__pycache__ \ 49 | /root/.npm 50 | ENV LC_ALL="en_US.UTF-8" 51 | ENV PATH="/env/bin:$PATH" 52 | ARG LEKTORIUM_LEKTOR_THEME 53 | ENV LEKTORIUM_LEKTOR_THEME="${LEKTORIUM_LEKTOR_THEME}" 54 | VOLUME /sessions 55 | ENTRYPOINT ["/entrypoint.sh"] 56 | -------------------------------------------------------------------------------- /src/lektorium/repo/interface.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import random 3 | import string 4 | from typing import Generator, Iterable, Mapping, Optional, Tuple 5 | 6 | 7 | class ExceptionBase(Exception): 8 | pass 9 | 10 | 11 | class DuplicateEditSession(ExceptionBase): 12 | pass 13 | 14 | 15 | class InvalidSessionState(ExceptionBase): 16 | pass 17 | 18 | 19 | class SessionNotFound(ExceptionBase): 20 | pass 21 | 22 | 23 | class Repo(metaclass=abc.ABCMeta): 24 | DEFAULT_USER = ('User Interface Py', 'user@interface.py') 25 | 26 | def generate_session_id(self) -> str: 27 | session_id = None 28 | while not session_id or session_id in self.sessions: 29 | session_id = ''.join(random.sample(string.ascii_lowercase, 8)) 30 | return session_id 31 | 32 | @property 33 | @abc.abstractmethod 34 | def sites(self) -> Iterable: 35 | pass 36 | 37 | @property 38 | @abc.abstractmethod 39 | def sessions(self) -> Mapping: 40 | pass 41 | 42 | @property 43 | @abc.abstractmethod 44 | def parked_sessions(self) -> Generator: 45 | pass 46 | 47 | @abc.abstractmethod 48 | def create_session( 49 | self, 50 | site_id: str, 51 | themes: Optional[Tuple[str]] = None, 52 | custodian: Optional[Tuple[str, str]] = None, 53 | ) -> str: 54 | pass 55 | 56 | @abc.abstractmethod 57 | def destroy_session(self, session_id: str) -> None: 58 | pass 59 | 60 | @abc.abstractmethod 61 | def park_session(self, session_id: str) -> None: 62 | pass 63 | 64 | @abc.abstractmethod 65 | def unpark_session(self, session_id: str) -> None: 66 | pass 67 | 68 | @abc.abstractmethod 69 | async def init_sessions(self): 70 | pass 71 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import tempfile 3 | from unittest.mock import MagicMock 4 | 5 | import async_timeout 6 | import pytest 7 | 8 | from lektorium.repo.local import AsyncLocalServer, LocalLektor 9 | 10 | 11 | class AsyncTestServer(AsyncLocalServer): 12 | START_PORT = 5000 13 | END_PORT = 5000 14 | 15 | def __init__(self, command): 16 | super().__init__() 17 | self.COMMAND = command 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_start_server_failed(): 22 | result = AsyncTestServer('echo').serve_lektor('/tmp') 23 | while callable(result): 24 | await asyncio.sleep(0.1) 25 | result = result()[0] 26 | assert result == 'Failed to start' 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_start_stop_server(): 31 | with tempfile.TemporaryDirectory() as tmp: 32 | cmd = 'echo "Finished prune"; sleep 1' 33 | server = AsyncTestServer(cmd) 34 | result = server.serve_lektor(tmp) 35 | while callable(result): 36 | await asyncio.sleep(0.1) 37 | result = result()[0] 38 | assert result == 'http://localhost:5000/' 39 | finalizer = MagicMock() 40 | server = server.stop_server(tmp, finalizer=finalizer) 41 | async with async_timeout.timeout(2): 42 | while not finalizer.call_count: 43 | await asyncio.sleep(0.1) 44 | 45 | 46 | @pytest.mark.asyncio 47 | @pytest.mark.xfail("sys.platform != 'darwin'") 48 | async def test_start_stop_lektor(tmpdir): 49 | LocalLektor.quickstart('a', 'b', tmpdir / 'c') 50 | server = AsyncLocalServer() 51 | result = server.serve_lektor(tmpdir / 'c') 52 | while callable(result): 53 | await asyncio.sleep(0.1) 54 | result = result()[0] 55 | finalizer = MagicMock() 56 | server.stop_server(tmpdir / 'c', finalizer=finalizer) 57 | async with async_timeout.timeout(2): 58 | while not finalizer.call_count: 59 | await asyncio.sleep(0.1) 60 | -------------------------------------------------------------------------------- /src/lektorium/repo/local/templates.py: -------------------------------------------------------------------------------- 1 | AWS_PROFILE_NAME = 'lektorium-aws-deploy' 2 | LECTOR_AWS_SERVER_NAME = 'lektorium-aws' 3 | 4 | AWS_SHARED_CREDENTIALS_FILE_TEMPLATE = f''' 5 | [{AWS_PROFILE_NAME}] 6 | aws_access_key_id = {{aws_key_id}} 7 | aws_secret_access_key = {{aws_secret_key}} 8 | ''' 9 | 10 | GITLAB_CI_TEMPLATE = f''' 11 | lektorium-aws-deploy: 12 | variables: 13 | LC_ALL: C.UTF-8 14 | LANG: C.UTF-8 15 | AWS_PROFILE: {AWS_PROFILE_NAME} 16 | GIT_SUBMODULE_STRATEGY: recursive 17 | GIT_SUBMODULE_FORCE_HTTPS: "true" 18 | script: 19 | - apk add --update python3 python3-dev libffi-dev openssl-dev build-base cargo 20 | - apk add --update py3-pip || true 21 | - python3 -m venv ./venv 22 | - . ./venv/bin/activate 23 | - pip3 install --upgrade "lektor==3.2.0" pytz "markupsafe==2.0.1" "Flask==1.1.4" 24 | - lektor plugins add lektor-s3 25 | - | 26 | themes=$(grep -E '^themes[[:space:]]*=' *.lektorproject | cut -d'=' -f2- | xargs) 27 | if [[ "$themes" == *","* ]]; then 28 | echo "Creating combined theme" 29 | rm -rf themes/combined_theme/ 30 | mkdir -p themes/combined_theme 31 | echo "$themes" | tr ',' '\\n' | while IFS= read -r item; do 32 | theme=$(echo "$item" | xargs) 33 | echo "Copying theme $theme" 34 | cp -a themes/$theme/. themes/combined_theme/ 35 | done 36 | sed -i '/^themes[[:space:]]*=.*/s//themes = combined_theme/' *.lektorproject 37 | fi 38 | - | 39 | echo "Lektor theme config:" 40 | grep "themes" *.lektorproject 41 | - lektor build 42 | - lektor deploy "{LECTOR_AWS_SERVER_NAME}" 43 | - deactivate 44 | only: 45 | - master 46 | ''' 47 | 48 | LECTOR_S3_SERVER_TEMPLATE = f''' 49 | [servers.{LECTOR_AWS_SERVER_NAME}] 50 | name = Lektorium AWS 51 | enabled = yes 52 | target = s3://{{s3_bucket_name}} 53 | cloudfront = {{cloudfront_id}} 54 | ''' 55 | 56 | EMPTY_COMMIT_PAYLOAD = ''' 57 | {"branch": "master", "commit_message": "Initial commit", "actions": []} 58 | ''' 59 | -------------------------------------------------------------------------------- /tests/test_gitlab_storage.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from lektorium.repo.local.storage import AWS, GitLab, GitlabStorage, GitStorage 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_gitlabstorage(tmpdir): 11 | remote_dir = tmpdir / 'remote' 12 | local_dir = tmpdir / 'local' 13 | remote_dir.mkdir() 14 | local_dir.mkdir() 15 | subprocess.check_call('git init --bare .', shell=True, cwd=remote_dir) 16 | subprocess.check_call(f'git clone {remote_dir} .', shell=True, cwd=local_dir) 17 | 18 | with mock.patch.multiple( 19 | AWS, 20 | create_s3_bucket=lambda *args, **kwargs: 'bucket_name', 21 | create_cloudfront_distribution=lambda *args, **kwargs: ('dist_id', 'domain_name'), 22 | open_bucket_access=lambda *args, **kwargs: None, 23 | ): 24 | async def init_project_mock(*args, **kwargs): 25 | return 'site_repo' 26 | with mock.patch.multiple(GitLab, init_project=init_project_mock): 27 | async def mock_create_site(*args, **kwargs): 28 | return local_dir, {} 29 | with mock.patch.multiple( 30 | GitStorage, 31 | __init__=lambda *args, **kwargs: None, 32 | create_site=mock_create_site, 33 | ): 34 | storage = GitlabStorage( 35 | 'git@server.domain:namespace/reponame.git', 36 | 'token', 37 | 'protocol', 38 | ) 39 | 40 | assert storage.repo == 'server.domain' 41 | assert storage.namespace == 'namespace' 42 | 43 | site_workdir, options = await storage.create_site(None, 'foo', '', 'bar') 44 | 45 | assert (local_dir / '.gitlab-ci.yml').exists() 46 | assert (local_dir / 'foo.lektorproject').exists() 47 | assert site_workdir == local_dir 48 | assert options == { 49 | 'cloudfront_domain_name': 'domain_name', 50 | 'url': 'https://domain_name', 51 | } 52 | 53 | assert await storage.create_site_repo('') == 'site_repo' 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | 4 | setuptools.setup( 5 | name='lektorium', 6 | 7 | use_scm_version=True, 8 | setup_requires=[ 9 | 'setuptools_scm', 10 | ], 11 | 12 | author='Anton Patrushev', 13 | author_email='ap@spherical.pm', 14 | maintainer='spherical.pm', 15 | maintainer_email='support@spherical.pm', 16 | 17 | description=( 18 | 'a pragmatic web content management solution ' 19 | 'for those with way too many little sites' 20 | ), 21 | license='MIT', 22 | 23 | packages=[ 24 | 'lektorium', 25 | 'lektorium.repo', 26 | 'lektorium.repo.local', 27 | ], 28 | package_dir={ 29 | '': 'src', 30 | }, 31 | package_data={ 32 | 'lektorium': [ 33 | 'client/*', 34 | 'client/*/*', 35 | 'client/*/*/*', 36 | ], 37 | }, 38 | install_requires=[ 39 | 'aiodocker', 40 | 'aiohttp-graphql<1.1', 41 | 'aiohttp==3.6.3', 42 | 'appdirs', 43 | 'authlib', 44 | 'beautifulsoup4', 45 | 'bidict', 46 | 'boto3', 47 | 'cached-property', 48 | 'graphene<3', 49 | 'graphql-core<3', 50 | 'graphql-server-core<1.1.2', 51 | 'importlib-resources ; python_version < "3.7"', 52 | 'lektor==3.2.0', 53 | 'Flask==1.1.4', 54 | 'markupsafe==2.0.1', 55 | 'more-itertools', 56 | 'python-dateutil<2.8.1', 57 | 'pyyaml', 58 | 'spherical-dev>=0.2.2,<0.3.0', 59 | 'wrapt', 60 | 'invoke', 61 | 'decorator', 62 | 'pytz', 63 | 'unsync', 64 | ], 65 | extras_require={ 66 | 'dev': [ 67 | 'aiohttp-devtools', 68 | 'aioresponses', 69 | 'aresponses', 70 | 'async-timeout', 71 | 'coverage<5', 72 | 'mypy', 73 | 'pep8-naming', 74 | 'pydocstyle', 75 | 'pytest-aiohttp', 76 | 'pytest-asyncio', 77 | 'pytest-cov', 78 | 'requests-mock', 79 | 'spherical-dev[dev]>=0.2.2,<0.3.0', 80 | 'wheel', 81 | ], 82 | }, 83 | zip_safe=False, 84 | ) 85 | -------------------------------------------------------------------------------- /src/lektorium/client/images/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # vuejs 107 | .DS_Store 108 | node_modules 109 | /dist 110 | /app 111 | 112 | # local env files 113 | .env.local 114 | .env.*.local 115 | 116 | # Log files 117 | npm-debug.log* 118 | yarn-debug.log* 119 | yarn-error.log* 120 | 121 | # Editor directories and files 122 | .idea 123 | .vscode 124 | *.suo 125 | *.ntvs* 126 | *.njsproj 127 | *.sln 128 | *.sw? 129 | 130 | /container/*whl 131 | package-lock.json 132 | /containers/server/key 133 | /containers/server/*.whl 134 | /invoke.yaml 135 | -------------------------------------------------------------------------------- /tests/test_gitlab_real.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | import os 4 | 5 | import pytest 6 | import requests 7 | import yaml 8 | 9 | from lektorium.repo.local import storage 10 | 11 | 12 | @pytest.mark.skipif( 13 | 'LEKTORIUM_GITLAB_TEST' not in os.environ, 14 | reason='no LEKTORIUM_GITLAB_TEST in env', 15 | ) 16 | def test_gitlab_real(): 17 | gitlab = os.environ['LEKTORIUM_GITLAB_TEST'] 18 | options = gitlab.split(':') 19 | options = dict(x.split('=') for x in options) 20 | gitlab = storage.GitLab(options) 21 | response = requests.get( 22 | ( 23 | '{scheme}://{host}/api/{api_version}/projects' 24 | '/{config}/repository/files/{filename}' 25 | ).format( 26 | filename=storage.GitStorage.CONFIG_FILENAME, 27 | **gitlab.options, 28 | ), 29 | headers=gitlab.headers, 30 | params=dict(ref='master'), 31 | ) 32 | response.raise_for_status() 33 | config = response.json()['content'] 34 | config = base64.b64decode(config) 35 | config = yaml.load(io.BytesIO(config)) 36 | response = requests.get( 37 | ( 38 | '{scheme}://{host}/api/{api_version}/projects' 39 | '/{config}' 40 | ).format(**gitlab.options), 41 | headers=gitlab.headers, 42 | ) 43 | response.raise_for_status() 44 | options['namespace'] = response.json()['namespace']['full_path'] 45 | for name in config.keys(): 46 | options['project'] = name 47 | gitlab = storage.GitLab(options) 48 | assert isinstance(gitlab.project_id, int) 49 | 50 | 51 | @pytest.mark.skipif( 52 | 'LEKTORIUM_GITLAB_TEST' not in os.environ, 53 | reason='no LEKTORIUM_GITLAB_TEST in env', 54 | ) 55 | def test_gitlab_merge_requests(): 56 | gitlab = os.environ['LEKTORIUM_GITLAB_TEST'] 57 | options = gitlab.split(':') 58 | options = dict(x.split('=') for x in options) 59 | options['protocol'] = options.pop('scheme') 60 | host = options.pop('host') 61 | config = options.pop('config') 62 | storage.run = lambda *args, **kwargs: None 63 | gitlab = storage.GitlabStorage( 64 | git=f'git@{host}:{config}/{storage.GitStorage.CONFIG_FILENAME}', 65 | **options, 66 | ) 67 | gitlab.get_merge_requests('000') 68 | gitlab.get_merge_requests('1111') 69 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pathlib 3 | import subprocess 4 | 5 | import pytest 6 | import requests_mock 7 | import wrapt 8 | 9 | from lektorium.repo import LocalRepo 10 | from lektorium.repo.local import ( 11 | FakeLektor, 12 | FakeServer, 13 | FileStorage, 14 | GitlabStorage, 15 | GitStorage, 16 | ) 17 | from lektorium.repo.memory import VALID_MERGE_REQUEST 18 | 19 | 20 | @wrapt.decorator 21 | def git_prepare(wrapped, instance, args, kwargs): 22 | assert not len(kwargs) 23 | tmpdir, = args 24 | tmpdir = tmpdir / 'lektorium' 25 | if not tmpdir.exists(): 26 | tmpdir.mkdir() 27 | if not (tmpdir / '.git').exists(): 28 | subprocess.check_call('git init --bare .', shell=True, cwd=tmpdir) 29 | return wrapped(pathlib.Path(tmpdir)) 30 | 31 | 32 | def local_repo(root_dir, storage_factory=FileStorage): 33 | repo = LocalRepo(storage_factory(root_dir), FakeServer(), FakeLektor) 34 | 35 | loop = asyncio.new_event_loop() 36 | asyncio.set_event_loop(loop) 37 | loop.run_until_complete(repo.create_site('bow', 'Buy Our Widgets')) 38 | loop.run_until_complete(repo.create_site('uci', 'Underpants Collectors International')) 39 | 40 | return repo 41 | 42 | 43 | def git_repo(root_dir): 44 | repo = local_repo(root_dir, git_prepare(GitStorage)) 45 | repo.config['bow'].data[GitlabStorage.GITLAB_SECTION_NAME] = { 46 | 'scheme': 'https', 47 | 'host': 'server', 48 | 'token': '123token456', 49 | 'namespace': 'user', 50 | 'project': 'project', 51 | } 52 | return repo 53 | 54 | 55 | @pytest.fixture 56 | def merge_requests(): 57 | with requests_mock.Mocker() as m: 58 | project = { 59 | 'id': 122, 60 | 'path_with_namespace': 'user/project', 61 | } 62 | merge_requests = [ 63 | VALID_MERGE_REQUEST, 64 | { 65 | 'id': 124, 66 | 'title': 'test2', 67 | 'target_branch': 'master', 68 | 'source_branch': 'test2', 69 | 'state': '2', 70 | 'web_url': 'url124', 71 | }, 72 | ] 73 | m.get( 74 | 'https://server/api/v4/groups/user/projects', 75 | json=[ 76 | project, 77 | ], 78 | ) 79 | m.get( 80 | f'https://server/api/v4/projects/{project["id"]}/merge_requests', 81 | json=merge_requests, 82 | ) 83 | yield merge_requests 84 | -------------------------------------------------------------------------------- /containers/nginx-proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | use epoll; 5 | } 6 | 7 | http { 8 | resolver 127.0.0.11 ipv6=off; 9 | default_type text/html; 10 | server { 11 | listen 80; 12 | server_name ENV_SERVER_NAME; 13 | 14 | location / { 15 | proxy_pass http://lektorium:8000/; 16 | 17 | sub_filter_once off; 18 | sub_filter_types text/html application/json; 19 | sub_filter 'http://lektorium-lektor-' 'https://$server_name/session/'; 20 | sub_filter ':5000' ''; 21 | } 22 | 23 | location ~ ^/session/([a-z][a-z][a-z][a-z][a-z][a-z][a-z][a-z])(.*)$ { 24 | set $session $1; 25 | set $path $2; 26 | 27 | if ($args ~ "path=%2Fsession%2F[a-z][a-z][a-z][a-z][a-z][a-z][a-z][a-z]%2F(.*)") { 28 | set $args path=$1; 29 | } 30 | 31 | proxy_pass http://lektorium-lektor-$session:5000$path$is_args$args; 32 | proxy_redirect '~^http://lektorium-lektor-([a-z][a-z][a-z][a-z][a-z][a-z][a-z][a-z]):5000/(.*)$' https://$server_name/session/$1/$2$is_args$args; 33 | 34 | sub_filter_once off; 35 | sub_filter_types text/html application/json application/javascript text/css; 36 | sub_filter 'href="./' 'href="/session/$session/'; 37 | sub_filter 'href="/' 'href="/session/$session/'; 38 | sub_filter "'href', '/admin/edit" "'href', '/session/$session/admin/edit"; 39 | sub_filter "'href', '//admin/edit" "'href', '/session/$session/admin/edit"; 40 | sub_filter 'site_root: "",' 'site_root: "/session/$session",'; 41 | sub_filter 'admin_root: "/admin",' 'admin_root: "/session/$session/admin",'; 42 | sub_filter 'admin_root: "/admin",' 'admin_root: "/session/$session/admin",'; 43 | sub_filter ' src="/admin/static/' ' src="/session/$session/admin/static/'; 44 | sub_filter ' 92 | 93 | 100 | -------------------------------------------------------------------------------- /src/lektorium/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Lektorium 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 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/lektorium/client/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Lektorium 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Anonymous 18 | 19 | 20 | 21 | Login 26 | 27 | 28 | 29 | 30 | 36 | 42 | 43 | 44 | {{ profile.name }} 45 | 46 | Profile 47 | 48 | 49 | Log out 50 | 51 | 52 | 53 | 54 | 55 | 56 | Log in 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 94 | 95 | 106 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | 2 | # Quickstart guide 3 | The current implementation of the hosted authoring central supports different methods of storing configuration of site sources and execution environments. Here we will discuss only main one: everything stored in GitLab repositories and executed in docker containers. 4 | 5 | Lektorium server needs a separate repository to store its configuration. It fetches this repository on start and uses `config.yml` from repository root as a registry of managed sites. The format of this registry file is described in a separate section at the end of this document. 6 | 7 | Docker is used to start lektorium, reverse proxy server and lektor instances. For GitLab access, lektorium uses ssh public key authentication so one needs to generate a keypair, place private key to `containers/server/key` and configure GitLab to allow access to lektorium and websites' repositories with this key. 8 | 9 | ## Install and start 10 | You need to create a deployment options file for you server in the `invoke.yaml` file. 11 | Example config (used on test server): 12 | 13 | ```yaml 14 | server-name: lektorium.patrushev.me 15 | auth: ap-lektorium.eu.auth0.com,w1oxvMsFpZCW4G224I8JR7D2et9yqTYo,Lektorium 16 | cfg: LOCAL:DOCKER,GIT=git@gitlab:apatrushev/lektorium.patrushev.me.git 17 | network: chisel 18 | env: 19 | GIT_MAIL: lektorium@lektorium.patrushev.me 20 | GIT_USER: Lektorium 21 | ``` 22 | 23 | * `server-name` host name of the hosted authoring central 24 | * `auth` auth0 provider configuration (described in a separate section) 25 | * `cfg` may be used to locate the configuration repository 26 | * `network` docker network to be used to start containers. Pay attention that it **can not** be the default docker network. 27 | * `env` additional environment options. `GIT_MAIL` and `GIT_USER` options are mandatory for the proper work of the git client. 28 | 29 | Deploy and start server: 30 | ``` 31 | inv -pe build run 32 | ``` 33 | 34 | Deploy and start reverse proxy: 35 | ``` 36 | inv -pe build-proxy-image run-proxy 37 | ``` 38 | 39 | ## Auth0 as authentication provider 40 | `auth` option of `invoke.yaml` configuration file consists of comma separated options of your auth0 application: domain, client id and api name. Please check auth0 documentation for more information or start lektorium **without authentication** by adding empty auth option to command line: 41 | ``` 42 | inv -pe build run --auth='' 43 | ``` 44 | 45 | ## Site configuration file 46 | 47 | Lektorium stores configuration of managed websites in YAML format. 48 | The file must be stored in the root of your git repo. 49 | 50 | Below is a structure of Lektorium config file which may require changes for your configuration: 51 | 52 | : 53 | name: 54 | owner: 55 | email: 56 | gitlab: 57 | scheme: 58 | host: 59 | namespace: 60 | project: 61 | [branch: ] 62 | [url: ] 63 | 64 | ### Supported settings 65 | 66 | 67 | #### site_id 68 | unique id of your site 69 | 70 | :Type: ``str`` 71 | 72 | #### site_id.name 73 | Your website name 74 | 75 | :Type: ``str`` 76 | 77 | #### site_id.owner 78 | website owner name 79 | 80 | :Type: ``str`` 81 | 82 | #### site_id.email 83 | website owner's email 84 | 85 | :Type: ``str`` 86 | 87 | #### site_id.url 88 | website production url 89 | 90 | :Type: ``str`` 91 | 92 | #### site_id.branch 93 | gitlab branch name 94 | 95 | :Type: ``str`` 96 | 97 | #### site_id.gitlab.schema 98 | network protocol to work with gitlab 99 | 100 | :Type: ``str`` 101 | 102 | #### site_id.gitlab.host 103 | gitlab host 104 | 105 | :Type: ``str`` 106 | 107 | #### site_id.gitlab.namespace 108 | gitlab namespace 109 | 110 | :Type: ``str`` 111 | 112 | #### site_id.gitlab.project 113 | gitlab project name 114 | 115 | :Type: ``str`` 116 | 117 | ### Example 118 | Below is an example of config file which may require changes for your configuration: 119 | 120 | ```yaml 121 | patrushev.me: 122 | email: apatrushev@gmail.com 123 | name: patrushev.me 124 | owner: Anton Patrushev 125 | gitlab: 126 | scheme: http 127 | host: gitlab 128 | namespace: apatrushev 129 | project: apatrushev.github.io 130 | branch: src 131 | url: https://patrushev.me 132 | spherical-website: 133 | email: mv@spherical.pm 134 | owner: Michael Vartanyan 135 | gitlab: 136 | scheme: http 137 | host: gitlab 138 | namespace: apatrushev 139 | project: spherical-website 140 | url: https://www.spherical.pm 141 | ``` 142 | -------------------------------------------------------------------------------- /tests/test_jwt.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import namedtuple 3 | 4 | import pytest 5 | 6 | from lektorium.jwt import GraphExecutionError, JWTMiddleware 7 | 8 | 9 | TEST_TOKEN = ( 10 | 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuaWNrbmFtZSI6Ik1heCBKZWtvdiIsI' 11 | 'mVtYWlsIjoibWpAbWFpbC5tZSIsImlhdCI6MTUxNjIzOTAyMn0.BfvCagmp3uLgjMCWqFQ' 12 | '7E85rcajlDSO7RTMaiGH2aQ4VyC573Cu3Wrvs6yq6xwBK0UqIFDL569pMIkrDFsJWROoEA' 13 | '6idJPSMwxCNXBK-lXcCoakGznX6fJ6S-7JF6mqF4An3hWx6XcX61Ck5j-ARBBq12FE_nr8' 14 | 'WQ9wFcwXWc6MOrzCcFGbY_SsSk5mVruQ9Wm3qZRza8DIAIJLJR9Kmuz6NnIqCw5aXVtDC3' 15 | 'f3FXWvG_W67CvEFiq-XoCAZPXnKoMFUqx-CAMaV5wPWKaMJxiA9iynksam-A5pimthDepS' 16 | 'FSVpulSoIyYmCAaIOLA0QKRWhHz3Ef8fGXBcpS1FwIQ' 17 | ) 18 | 19 | TEST_HEADERS = {'Authorization': f'Bearer {TEST_TOKEN}.{TEST_TOKEN}'} 20 | 21 | TEST_JWK = { 22 | "kty": "RSA", 23 | "e": "AQAB", 24 | "n": ( 25 | 'nzyis1ZjfNB0bBgKFMSvvkTtwlvBsaJq7S5wA-kzeVOVpVWwkWdVha4s38XM_pa_yr47' 26 | 'av7-z3VTmvDRyAHcaT92whREFpLv9cj5lTeJSibyr_Mrm_YtjCZVWgaOYIhwrXwKLqPr' 27 | '_11inWsAkfIytvHWTxZYEcXLgAXFuUuaS3uF9gEiNQwzGTU1v0FqkqTBr4B8nW3HCN47' 28 | 'XUu0t8Y0e-lf4s4OxQawWD79J9_5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMq' 29 | 'tvWbV6L11BWkpzGXSW4Hv43qa-GSYOD2QU68Mb59oSk2OB-BtOLpJofmbGEGgvmwyCI9' 30 | 'Mw' 31 | ), 32 | } 33 | 34 | 35 | @pytest.fixture 36 | def jwt_middleware(): 37 | return JWTMiddleware('test.auth0.com') 38 | 39 | 40 | def test_get_token_auth(jwt_middleware): 41 | assert jwt_middleware.get_token_auth(TEST_HEADERS) == (TEST_TOKEN, TEST_TOKEN) 42 | 43 | with pytest.raises(GraphExecutionError) as excinfo: 44 | jwt_middleware.get_token_auth({}) 45 | assert 'Authorization header is expected' == str(excinfo.value) 46 | 47 | with pytest.raises(GraphExecutionError) as excinfo: 48 | jwt_middleware.get_token_auth(dict(Authorization='token testtoken')) 49 | assert 'Authorization header must be Bearer token' == str(excinfo.value) 50 | 51 | with pytest.raises(GraphExecutionError) as excinfo: 52 | jwt_middleware.get_token_auth(dict(Authorization='testtoken')) 53 | assert 'Authorization header must be Bearer token' == str(excinfo.value) 54 | 55 | with pytest.raises(GraphExecutionError) as excinfo: 56 | jwt_middleware.get_token_auth(dict(Authorization='token token token')) 57 | assert 'Authorization header must be Bearer token' == str(excinfo.value) 58 | 59 | 60 | def test_decode_token(jwt_middleware): 61 | token = jwt_middleware.decode_token(TEST_TOKEN, TEST_JWK) 62 | assert token['nickname'] == 'Max Jekov' 63 | with pytest.raises(GraphExecutionError): 64 | jwt_middleware.decode_token('', TEST_JWK) 65 | with pytest.raises(ValueError): 66 | jwt_middleware.decode_token(TEST_TOKEN, '') 67 | 68 | 69 | def test_jwt_middleware_init(): 70 | with pytest.raises(ValueError): 71 | JWTMiddleware(None) 72 | with pytest.raises(ValueError): 73 | JWTMiddleware('') 74 | with pytest.raises(ValueError): 75 | JWTMiddleware('aaa.bbb.com') 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_public_key(aresponses, jwt_middleware): 80 | def response_handler(request): 81 | return aresponses.Response( 82 | status=200, 83 | headers={'Content-Type': 'application/json'}, 84 | body=b'{"public_key": "somekey"}', 85 | ) 86 | aresponses.add( 87 | jwt_middleware.auth0_domain, 88 | '/.well-known/jwks.json', 89 | 'get', 90 | response_handler, 91 | ) 92 | key = await jwt_middleware.public_key 93 | assert key == {'public_key': 'somekey'} 94 | 95 | 96 | def test_m(monkeypatch): 97 | Info = namedtuple('Info', 'context') 98 | Request = namedtuple('Request', 'headers') 99 | info = Info({'request': Request(TEST_HEADERS)}) 100 | assert info.context['request'].headers == TEST_HEADERS 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_jwt_resolve(aresponses, jwt_middleware, monkeypatch): 105 | Info = namedtuple('Info', 'context') 106 | Request = namedtuple('Request', 'headers') 107 | info = Info({'request': Request(TEST_HEADERS)}) 108 | 109 | def test_next(root, info, **kwargs): 110 | return info 111 | 112 | def response_handler(request): 113 | return aresponses.Response( 114 | status=200, 115 | headers={'Content-Type': 'application/json'}, 116 | body=bytes(json.dumps(TEST_JWK), encoding='utf-8'), 117 | ) 118 | 119 | aresponses.add( 120 | jwt_middleware.auth0_domain, 121 | '/.well-known/jwks.json', 122 | 'get', 123 | response_handler, 124 | ) 125 | resolve = await jwt_middleware.resolve(test_next, None, info) 126 | assert resolve.context['userdata'] == ('Max Jekov', 'mj@mail.me') 127 | -------------------------------------------------------------------------------- /src/lektorium/aws.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from uuid import uuid4 3 | 4 | import boto3 5 | from cached_property import cached_property 6 | 7 | 8 | BUCKET_POLICY_TEMPLATE = '''{{ 9 | "Version": "2012-10-17", 10 | "Statement": [{{ 11 | "Sid": "PublicReadGetObject", 12 | "Effect": "Allow", 13 | "Principal": "*", 14 | "Action": "s3:GetObject", 15 | "Resource": "arn:aws:s3:::{bucket_name}/*" 16 | }}] 17 | }} 18 | ''' 19 | 20 | 21 | class AWS: 22 | S3_PREFIX = 'lektorium-' 23 | S3_SUFFIX = 'amazonaws.com' 24 | SLEEP_TIMEOUT = 2 25 | 26 | @cached_property 27 | def s3_client(self): 28 | return boto3.client('s3') 29 | 30 | @cached_property 31 | def cloudfront_client(self): 32 | return boto3.client('cloudfront') 33 | 34 | @staticmethod 35 | def _get_status(response): 36 | return response.get('ResponseMetadata', {}).get('HTTPStatusCode', -1) 37 | 38 | @staticmethod 39 | def _raise_if_not_status(response, response_code, error_text): 40 | if AWS._get_status(response) != response_code: 41 | raise Exception(error_text) 42 | 43 | def create_s3_bucket(self, site_id, prefix=''): 44 | prefix = prefix or self.S3_PREFIX 45 | bucket_name = prefix + site_id 46 | response = self.s3_client.create_bucket(Bucket=bucket_name) 47 | self._raise_if_not_status( 48 | response, 200, 49 | 'Failed to create S3 bucket', 50 | ) 51 | return bucket_name 52 | 53 | def open_bucket_access(self, bucket_name): 54 | # Bucket may fail to be created and registered at this moment 55 | # Retry a few times and wait a bit in case bucket is not found 56 | for _ in range(3): 57 | response = self.s3_client.delete_public_access_block(Bucket=bucket_name) 58 | response_code = self._get_status(response) 59 | if response_code == 404: 60 | sleep(self.SLEEP_TIMEOUT) 61 | elif response_code == 204: 62 | break 63 | else: 64 | raise Exception('Failed to remove bucket public access block') 65 | 66 | response = self.s3_client.put_bucket_policy( 67 | Bucket=bucket_name, 68 | Policy=BUCKET_POLICY_TEMPLATE.format(bucket_name=bucket_name), 69 | ) 70 | self._raise_if_not_status( 71 | response, 204, 72 | 'Failed to set bucket access policy', 73 | ) 74 | 75 | response = self.s3_client.put_bucket_website( 76 | Bucket=bucket_name, 77 | WebsiteConfiguration=dict( 78 | ErrorDocument=dict( 79 | Key='404.html', 80 | ), 81 | IndexDocument=dict( 82 | Suffix='index.html', 83 | ), 84 | ), 85 | ) 86 | self._raise_if_not_status( 87 | response, 200, 88 | 'Failed to make S3 bucket website', 89 | ) 90 | 91 | def create_cloudfront_distribution(self, bucket_name): 92 | region = self.s3_client.meta.region_name 93 | domain = f'{bucket_name}.s3-website-{region}.{self.S3_SUFFIX}' 94 | response = self.cloudfront_client.create_distribution( 95 | DistributionConfig=dict( 96 | CallerReference=str(uuid4()), 97 | Comment='Lektorium', 98 | Enabled=True, 99 | Origins=dict( 100 | Quantity=1, 101 | Items=[dict( 102 | Id='1', 103 | DomainName=domain, 104 | CustomOriginConfig=dict( 105 | HTTPPort=80, 106 | HTTPSPort=443, 107 | OriginProtocolPolicy='http-only', 108 | ), 109 | )], 110 | ), 111 | DefaultCacheBehavior=dict( 112 | TargetOriginId='1', 113 | ViewerProtocolPolicy='redirect-to-https', 114 | TrustedSigners=dict(Quantity=0, Enabled=False), 115 | ForwardedValues=dict( 116 | Cookies={'Forward': 'all'}, 117 | Headers=dict(Quantity=0), 118 | QueryString=False, 119 | QueryStringCacheKeys=dict(Quantity=0), 120 | ), 121 | MinTTL=1000, 122 | ), 123 | ), 124 | ) 125 | self._raise_if_not_status( 126 | response, 201, 127 | 'Failed to create CloudFront distribution', 128 | ) 129 | distribution_data = response['Distribution'] 130 | return distribution_data['Id'], distribution_data['DomainName'] 131 | -------------------------------------------------------------------------------- /tests/test_repo.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import pytest 4 | from conftest import git_repo, local_repo 5 | 6 | from lektorium.repo import ( 7 | SITES, 8 | DuplicateEditSession, 9 | InvalidSessionState, 10 | ListRepo, 11 | SessionNotFound, 12 | ) 13 | from lektorium.repo.local import FileStorage, GitStorage 14 | from lektorium.repo.memory import VALID_MERGE_REQUEST 15 | 16 | 17 | def memory_repo(_): 18 | return ListRepo(copy.deepcopy(SITES)) 19 | 20 | 21 | @pytest.fixture(scope='function', params=[memory_repo, local_repo, git_repo]) 22 | def repo(request, tmpdir): 23 | return request.param(tmpdir) 24 | 25 | 26 | def test_site_attributes(repo): 27 | attributes = set({a: s[a] for s in repo.sites for a in s}) 28 | assert attributes.issuperset({ 29 | 'custodian', 30 | 'custodian_email', 31 | 'production_url', 32 | 'sessions', 33 | 'site_id', 34 | 'site_name', 35 | 'staging_url', 36 | }) 37 | 38 | 39 | def test_session_attributes(repo): 40 | repo.park_session(repo.create_session('uci')) 41 | repo.create_session('uci') 42 | attributes = set(a for s, _ in repo.sessions.values() for a in s) 43 | assert attributes == { 44 | 'creation_time', 45 | 'custodian', 46 | 'custodian_email', 47 | 'edit_url', 48 | 'parked_time', 49 | 'session_id', 50 | } 51 | 52 | 53 | def test_create_session(repo): 54 | sesison_before = len(list(repo.sessions)) 55 | assert isinstance(repo.create_session('uci'), str) 56 | assert len(list(repo.sessions)) == sesison_before + 1 57 | 58 | 59 | def test_create_session_other_exist(repo): 60 | assert isinstance(repo.create_session('uci'), str) 61 | with pytest.raises(DuplicateEditSession): 62 | repo.create_session('uci') 63 | 64 | 65 | def test_destroy_session(repo): 66 | repo.create_session('uci') 67 | session_count_before = len(list(repo.sessions)) 68 | repo.destroy_session(list(repo.sessions)[0]) 69 | assert len(list(repo.sessions)) == session_count_before - 1 70 | 71 | 72 | def test_destroy_unknown_session(repo): 73 | with pytest.raises(SessionNotFound): 74 | repo.destroy_session('test12345') 75 | 76 | 77 | def test_park_session(repo): 78 | session_id = repo.create_session('uci') 79 | session_count_before = len(list(repo.parked_sessions)) 80 | repo.park_session(session_id) 81 | assert len(list(repo.parked_sessions)) == session_count_before + 1 82 | 83 | 84 | def test_park_unknown_session(repo): 85 | with pytest.raises(SessionNotFound): 86 | repo.park_session('test12345') 87 | 88 | 89 | def test_park_parked_session(repo): 90 | session_id = repo.create_session('uci') 91 | repo.park_session(session_id) 92 | with pytest.raises(InvalidSessionState): 93 | repo.park_session(session_id) 94 | 95 | 96 | def test_unpark_session(repo): 97 | session_id = repo.create_session('uci') 98 | repo.park_session(session_id) 99 | session_count_before = len(list(repo.parked_sessions)) 100 | repo.unpark_session(session_id) 101 | assert len(list(repo.parked_sessions)) == session_count_before - 1 102 | 103 | 104 | def test_unpark_session_another_exist(repo): 105 | session_id = repo.create_session('uci') 106 | repo.park_session(session_id) 107 | repo.create_session('uci') 108 | with pytest.raises(DuplicateEditSession): 109 | repo.unpark_session(session_id) 110 | 111 | 112 | def test_unpark_unknown_session(repo): 113 | with pytest.raises(SessionNotFound): 114 | repo.unpark_session('test12345') 115 | 116 | 117 | def test_unpark_unkparked_session(repo): 118 | session_id = repo.create_session('uci') 119 | with pytest.raises(InvalidSessionState): 120 | repo.unpark_session(session_id) 121 | 122 | 123 | def test_sessions_in_site(repo): 124 | site = {x['site_id']: x for x in repo.sites}['uci'] 125 | session_count_before = len(site['sessions']) 126 | session_id = repo.create_session('uci') 127 | assert len(site['sessions']) == session_count_before + 1 128 | repo.destroy_session(session_id) 129 | assert len(site['sessions']) == session_count_before 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_create_site(repo): 134 | site_count_before = len(list(repo.sites)) 135 | await repo.create_site('cri', 'Common Redundant Idioms') 136 | assert len(list(repo.sites)) == site_count_before + 1 137 | 138 | 139 | @pytest.mark.skip(reason='too wide test') 140 | def test_releasing(repo, merge_requests): 141 | result = list(repo.releasing) 142 | request = { 143 | 'site_name': 'Buy Our Widgets', 144 | **VALID_MERGE_REQUEST, 145 | } 146 | if isinstance(getattr(repo, 'storage', None), (FileStorage, GitStorage)): 147 | return 148 | assert result == [request] 149 | 150 | 151 | @pytest.mark.xfail 152 | def test_request_release(repo): 153 | session_id = repo.create_session('uci') 154 | repo.request_release(session_id) 155 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import os 3 | import pathlib 4 | import shutil 5 | 6 | import pytest 7 | import requests_mock 8 | from conftest import git_prepare 9 | 10 | from lektorium.repo.local import ( 11 | FileStorage, 12 | GitlabStorage, 13 | GitStorage, 14 | LocalLektor, 15 | ) 16 | from lektorium.repo.local.objects import Site 17 | 18 | 19 | @pytest.fixture( 20 | scope='function', 21 | params=[ 22 | FileStorage, 23 | git_prepare(GitStorage), 24 | ], 25 | ) 26 | def storage_factory(request): 27 | return request.param 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_everything(tmpdir, storage_factory): 32 | storage = storage_factory(tmpdir) 33 | assert isinstance(storage.config, dict) 34 | site_id = 'test-site' 35 | path, options = await storage.create_site( 36 | LocalLektor, 37 | 'Site Name', 38 | 'Site Owner', 39 | site_id, 40 | ) 41 | assert path.exists() 42 | storage.site_config(site_id).get('project.name') 43 | storage.config[site_id] = Site(site_id, None, **options) 44 | storage = storage_factory(tmpdir) 45 | assert len(storage.config) 46 | session_dir = tmpdir / 'session-id' 47 | assert not session_dir.exists() 48 | storage.create_session(site_id, 'session-id', session_dir) 49 | assert len(session_dir.listdir()) 50 | if (tmpdir / site_id).exists(): 51 | shutil.rmtree(tmpdir / site_id) 52 | storage.site_config(site_id).get('project.name') 53 | 54 | 55 | @pytest.mark.skip(reason='too wide test') 56 | @pytest.mark.asyncio 57 | async def test_request_release(tmpdir): 58 | site_id, storage = 'site-id', git_prepare(GitStorage)(tmpdir) 59 | path, options = await storage.create_site(LocalLektor, 's', 'o', site_id) 60 | storage.config[site_id] = Site(site_id, None, **options) 61 | session_id = 'session-id' 62 | session_dir = tmpdir / session_id 63 | storage.create_session(site_id, session_id, session_dir) 64 | page = (pathlib.Path(session_dir) / 'content' / 'contents.lr') 65 | page.write_text(os.linesep.join((page.read_text(), 'Signature.'))) 66 | site = storage.config[site_id] 67 | site.sessions[session_id] = dict(custodian='user', custodian_email='email') 68 | site.data[GitlabStorage.GITLAB_SECTION_NAME] = dict( 69 | scheme='https', 70 | host='server', 71 | namespace='user', 72 | project='project', 73 | token='token', 74 | ) 75 | storage.config[site_id] = site 76 | with requests_mock.Mocker() as m: 77 | projects = [{'id': 123, 'path_with_namespace': 'user/project'}] 78 | m.get('https://server/api/v4/projects', json=projects) 79 | post_url = 'https://server/api/v4/projects/123/merge_requests' 80 | m.post(post_url) 81 | storage.request_release(site_id, session_id, session_dir) 82 | assert m.call_count == 2 83 | last_request = m.request_history[-1] 84 | assert last_request.url == post_url 85 | assert last_request.method == 'POST' 86 | assert last_request.body == ( 87 | 'source_branch=session-session-id&' 88 | 'target_branch=master&' 89 | 'title=Request+from%3A+%22user%22+%3Cemail%3E' 90 | ) 91 | 92 | 93 | @pytest.mark.skip(reason='too wide test') 94 | @pytest.mark.asyncio 95 | async def test_get_merge_requests(tmpdir, merge_requests): 96 | site_id, storage = 'site-id', git_prepare(GitStorage)(tmpdir) 97 | path, options = await storage.create_site(LocalLektor, 's', 'o', site_id) 98 | storage.config[site_id] = Site(site_id, None, **options) 99 | site = storage.config[site_id] 100 | site.data[GitlabStorage.GITLAB_SECTION_NAME] = dict( 101 | scheme='https', 102 | host='server', 103 | namespace='user', 104 | project='project', 105 | token='token', 106 | ) 107 | storage.config[site_id] = site 108 | result = storage.get_merge_requests(site_id) 109 | assert result == merge_requests 110 | 111 | 112 | CONFIG = ''' 113 | company-website: 114 | email: mv@company.pm 115 | gitlab: 116 | host: gitlab 117 | namespace: user 118 | project: company-website 119 | owner: Muser Museryan 120 | site.url: 121 | branch: src 122 | email: user@example.com 123 | owner: User Userovich 124 | repo: git@gitlab:user/site.repo.git 125 | url: https://site.url 126 | '''.lstrip() 127 | 128 | 129 | def test_config_gitlab_repo(tmpdir): 130 | storage = git_prepare(GitStorage)(tmpdir) 131 | storage._config_path.write_text(CONFIG) 132 | 133 | def site_config_getter(_): 134 | return collections.defaultdict(type(None)) 135 | 136 | config = GitStorage.load_config(storage._config_path, site_config_getter) 137 | site = config['company-website'] 138 | gitlab_repo = site['repo'] 139 | assert gitlab_repo == 'git@gitlab:user/company-website.git' 140 | assert site.sessions is not None 141 | config['company-website'] = config['company-website'] 142 | assert CONFIG == storage._config_path.read_text() 143 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Lektorium 2 | 3 | 4 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 5 | 6 | The following is a set of guidelines for contributing to [Lektorium](https://github.com/sphericalpm/lektorium). These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 7 | 8 | #### Table Of Contents 9 | 10 | [Code Of Conduct](#code-of-conduct) 11 | 12 | [What should I know before I get started?](#what-should-i-know-before-i-get-started) 13 | 14 | 15 | [How Can I Contribute](#how-can-i-contribute) 16 | 17 | 18 | [Style Guides](#styleguides) 19 | 20 | * [Git Commit Messages](###Git-Commit-Messages) 21 | 22 | * [Python Style Guide](###Python-Styleguide) 23 | 24 | * [HTML/CSS Style Guide](###HTML/CSS-Styleguide) 25 | 26 | * [JS Style Guide](###Java-Script-Style-Guide) 27 | 28 | ## Code Of Conduct 29 | 30 | This project and everyone participating in it is governed by the Atom Code of Conduct. By participating, you are expected to uphold this code. 31 | 32 | 1. We treat everyone and each as a friend and with respect. In particular: personal attacks, insulting, indignity for any reason, critics and stereotyping on the basis of personal attributes the receiver cannot change (such as gender, age, health condition, sexual orientation, etc) are not allowed. We keep close to each other, joke, fool around and have fun from our interaction, keeping within the limits described above. 33 | 34 | 2. We work as a team. Do not compete or brag about any of our qualities, except the one of how well we support others and how we share our knowledge. Help to your colleagues is the most important KPI! 35 | 36 | 3. There is no such thing as a stupid question. Repetitive questions can be annoying but never stupid. 37 | 38 | 4. We say what we think and do what we say. [Self-] deception, especially in assessing own abilities does not make any sense: everything is here to learn something new, and in order to improve one needs to know the true baseline. 39 | 40 | 5. We don't owe anything to one another, except what we had promised. Consequently, we do not promise what we can not handle or are not going to do. 41 | 42 | 6. While point 4 above is very important, we understand that everyone has their day job, study, family, and other sources of unforeseen circumstances. If something affects work to be done, we do not quarrel but try to find ways to help our colleague and the project. If such "oopses" occur too often, we review how much time this colleague can actually contribute to the project, and act accordingly. 43 | 44 | 7. Code quality is its most important characteristic. It is always better to take longer than to contribute produce substandard code. It is worth to ask for help from your teammate than declare code you are not sure about as done. We respect each other's time and we do not declare code finished unless it meets basic quality requirements (it builds, runs, is adequately covered with tests, complies to basic style guides, etc). 45 | 46 | 8. The whole codebase belongs to all of us. Everyone looks at their own code as critically as at someone else's, and does not defend own code from legitimate criticism. The author is always eager to receive critique of their code and is grateful for such critique. 47 | 48 | 9. We accept [Michael](https://github.com/mvartanyan) and [Max](https://github.com/jekoff) as an imaginary customer voice. Technical decision making we make together. 49 | 50 | 10. Any proposals can and should be voiced, considered and debated. When the decision is taken, we are moving accordingly without deviation, except something previously unknown affects on the context in which the decision was taken. In such cases, we reconsider the decision. It happens and that is totally fine! 51 | 52 | ## What should I know before I get started? 53 | 54 | ## How Can I Contribute 55 | 56 | ## Style Guides 57 | 58 | ### Git Commit Messages 59 | 60 | * Use the present tense ("Add feature" not "Added feature") 61 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 62 | * Limit the first line to 72 characters or less 63 | * Reference issues and pull requests liberally after the first line 64 | * When only changing documentation, include `[ci skip]` in the commit title 65 | 66 | ### Python Style Guide 67 | * We use [PEP-8](https://www.python.org/dev/peps/pep-0008/) in our project. 68 | * Also we use [PEP-484](https://www.python.org/dev/peps/pep-0484/) for type hints 69 | * We use [google-style](http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) docstrings 70 | * Additions 71 | * Use 4 spaces indentation please! 72 | * avoid `\` at the end of line as much as possible 73 | * 100 symbols are soft limit for line length 74 | * 120 symbols are hard limit for line length 75 | * It is also recommended to use following code analysis tools: 76 | * [flake8](https://pypi.org/project/flake8/) 77 | * [pylint](https://pypi.org/project/pylint/) 78 | 79 | ### HTML/CSS Style Guide 80 | * We stick to Google [HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html) 81 | 82 | ### Java Script Style Guide 83 | * We use [Airbnb style guide for JS](https://github.com/airbnb/javascript) 84 | -------------------------------------------------------------------------------- /src/lektorium/client/scripts/authService.js: -------------------------------------------------------------------------------- 1 | function parseJwt(token) { 2 | var base64Url = token.split('.')[1]; 3 | var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 4 | var jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { 5 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 6 | }).join('')); 7 | 8 | return JSON.parse(jsonPayload); 9 | }; 10 | 11 | function authService() { 12 | const webAuth = new auth0.WebAuth({ 13 | domain: _.get(lektoriumAuth0Config, 'domain'), 14 | redirectUri: `${window.location.origin}/callback`, 15 | clientID: _.get(lektoriumAuth0Config, 'id'), 16 | audience: _.get(lektoriumAuth0Config, 'api'), 17 | responseType: 'id_token token', 18 | scope: 'openid profile email' 19 | }); 20 | 21 | const localStorageKey = 'loggedIn'; 22 | const loginEvent = 'loginEvent'; 23 | 24 | class AuthService extends EventEmitter3 { 25 | idToken = null; 26 | accessToken = null; 27 | profile = null; 28 | tokenExpiry = null; 29 | 30 | login(customState) { 31 | webAuth.authorize({ 32 | appState: customState 33 | }); 34 | }; 35 | 36 | logOut() { 37 | localStorage.removeItem(localStorageKey); 38 | 39 | this.idToken = null; 40 | this.accessToken = null; 41 | this.tokenExpiry = null; 42 | this.profile = null; 43 | 44 | webAuth.logout({ 45 | returnTo: `${window.location.origin}` 46 | }); 47 | 48 | this.emit(loginEvent, { loggedIn: false }); 49 | }; 50 | 51 | handleAuthentication() { 52 | return new Promise((resolve, reject) => { 53 | webAuth.parseHash((err, authResult) => { 54 | if (err) { 55 | this.emit(loginEvent, { 56 | loggedIn: false, 57 | error: err, 58 | errorMsg: err.statusText 59 | }); 60 | reject(err); 61 | } else { 62 | this.localLogin(authResult); 63 | resolve(authResult.idToken); 64 | } 65 | }); 66 | }); 67 | }; 68 | 69 | isAuthenticated() { 70 | return ( 71 | Date.now() < this.tokenExpiry && 72 | localStorage.getItem(localStorageKey) === 'true' 73 | ); 74 | }; 75 | 76 | isTokenValid() { 77 | return this.idToken && this.tokenExpiry && Date.now() < this.tokenExpiry; 78 | }; 79 | 80 | getTokens() { 81 | return new Promise((resolve, reject) => { 82 | if (this.isTokenValid()) { 83 | resolve([this.idToken, this.accessToken]); 84 | } else if (this.isAuthenticated()) { 85 | this.renewTokens().then(authResult => { 86 | resolve([authResult.idToken, authResult.accessToken]); 87 | }, reject); 88 | } else { 89 | resolve(); 90 | } 91 | }); 92 | }; 93 | 94 | localLogin(authResult) { 95 | var profile = JSON.parse(JSON.stringify(authResult.idTokenPayload)); 96 | profile.access_token = parseJwt(authResult.accessToken); 97 | this.idToken = authResult.idToken; 98 | this.accessToken = authResult.accessToken; 99 | this.profile = profile; 100 | 101 | // Convert the expiry time from seconds to milliseconds, 102 | // required by the Date constructor 103 | this.tokenExpiry = new Date(this.profile.exp * 1000); 104 | 105 | localStorage.setItem(localStorageKey, 'true'); 106 | 107 | this.emit(loginEvent, { 108 | loggedIn: true, 109 | profile: profile, 110 | state: authResult.appState || {} 111 | }); 112 | }; 113 | 114 | renewTokens() { 115 | return new Promise((resolve, reject) => { 116 | if (localStorage.getItem(localStorageKey) !== 'true') { 117 | return reject('Not logged in'); 118 | } 119 | 120 | webAuth.checkSession({}, (err, authResult) => { 121 | if (err) { 122 | reject(err); 123 | } else { 124 | this.localLogin(authResult); 125 | resolve(authResult); 126 | } 127 | }); 128 | }); 129 | } 130 | }; 131 | 132 | const service = new AuthService(); 133 | return service; 134 | }; 135 | 136 | function authServiceInstall(Vue) { 137 | var service = authService(); 138 | Vue.prototype.$auth = service; 139 | Vue.mixin({ 140 | created() { 141 | if (this.handleLoginEvent) { 142 | service.addListener('loginEvent', this.handleLoginEvent); 143 | }; 144 | }, 145 | 146 | destroyed() { 147 | if (this.handleLoginEvent) { 148 | service.removeListener('loginEvent', this.handleLoginEvent); 149 | }; 150 | }, 151 | }); 152 | }; 153 | -------------------------------------------------------------------------------- /tests/test_auth0_client.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import pytest 4 | from aioresponses import aioresponses 5 | 6 | from lektorium.auth0 import Auth0Client, Auth0Error, FakeAuth0Client 7 | 8 | 9 | TEST_TOKEN = {'access_token': 'test_token'} 10 | TEST_AUTH_DATA = { 11 | 'data-auth0-domain': 'testdomain', 12 | 'data-auth0-api': 'testapi', 13 | 'data-auth0-management-id': 'testid', 14 | 'data-auth0-management-secret': 'testsecret', 15 | } 16 | 17 | 18 | @pytest.fixture 19 | def auth0_client(): 20 | test_auth_data = { 21 | 'data-auth0-domain': 'testdomain', 22 | 'data-auth0-api': 'testapi', 23 | 'data-auth0-management-id': 'testid', 24 | 'data-auth0-management-secret': 'testsecret', 25 | } 26 | return Auth0Client(test_auth_data) 27 | 28 | 29 | @pytest.fixture 30 | def fake_auth0_client(): 31 | return FakeAuth0Client() 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_fake_auth_token(fake_auth0_client): 36 | token = await fake_auth0_client.auth_token 37 | assert token == 'test_token' 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_fake_get_user_permissions(fake_auth0_client): 42 | with pytest.raises(Auth0Error): 43 | await fake_auth0_client.get_user_permissions('wrong_id') 44 | 45 | 46 | @pytest.fixture 47 | def mocked(auth0_client): 48 | with aioresponses() as mocked: 49 | mocked.post(auth0_client.token_url, status=200, payload=TEST_TOKEN) 50 | yield mocked 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_auth_token(auth0_client, mocked): 55 | assert (await auth0_client.auth_token) == 'test_token' 56 | mocked.post(auth0_client.token_url, status=404) 57 | auth0_client.token_time = 0 58 | requests = list(mocked.requests.values()) 59 | assert len(requests) == 1 60 | assert len(requests[0]) == 1 61 | request = requests[0][0] 62 | assert request.kwargs['json']['audience'] == f'{auth0_client.audience}/' 63 | with pytest.raises(Auth0Error): 64 | await auth0_client.auth_token 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_get_users(auth0_client, mocked): 69 | url = f'{auth0_client.audience}/users?fields=name,nickname,email,user_id&per_page=100' 70 | users_response = [{ 71 | 'username': 'mjekov', 72 | }] 73 | mocked.get(url, status=200, payload=users_response) 74 | assert (await auth0_client.get_users()) == users_response 75 | mocked.get(url, status=400) 76 | auth0_client._cache.pop(('users',), None) 77 | with pytest.raises(Auth0Error): 78 | await auth0_client.get_users() 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_get_user_permissions(auth0_client, mocked): 83 | url = f'{auth0_client.audience}/users/user_id/permissions?per_page=100' 84 | permissions_response = [{ 85 | 'permission_name': 'read:projects', 86 | }] 87 | mocked.get(url, status=200, payload=permissions_response) 88 | response = await auth0_client.get_user_permissions('user_id') 89 | assert response == permissions_response 90 | auth0_client._cache.pop(('user_permissions', 'user_id'), None) 91 | mocked.get(url, status=400) 92 | with pytest.raises(Auth0Error): 93 | await auth0_client.get_user_permissions('user_id') 94 | 95 | 96 | @pytest.mark.asyncio 97 | async def test_set_user_permissions(auth0_client, mocked): 98 | api_permissions_url = f'{auth0_client.audience}/resource-servers?per_page=100' 99 | mocked.get( 100 | api_permissions_url, 101 | status=200, 102 | payload=[{ 103 | 'identifier': auth0_client.api_id, 104 | 'scopes': [{ 105 | 'value': 'perm-id', 106 | 'description': 'perm description', 107 | }], 108 | }], 109 | ) 110 | url = f'{auth0_client.audience}/resource-servers/{auth0_client.api_id}' 111 | mocked.patch(url, status=200) 112 | url = f'{auth0_client.audience}/users/user_id/permissions' 113 | mocked.post(url, status=201) 114 | assert await auth0_client.set_user_permissions('user_id', ['permission']) 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_delete_user_permissions(auth0_client, mocked): 119 | url = f'{auth0_client.audience}/users/user_id/permissions' 120 | call = functools.partial( 121 | auth0_client.delete_user_permissions, 122 | 'user_id', 123 | ['permission'], 124 | ) 125 | mocked.delete(url, status=204) 126 | assert await call() 127 | mocked.delete(url, status=400) 128 | with pytest.raises(Auth0Error): 129 | await call() 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_get_api_permissions(auth0_client, mocked): 134 | url = f'{auth0_client.audience}/resource-servers?per_page=100' 135 | permissions = [{ 136 | 'value': 'perm-id', 137 | 'description': 'perm description', 138 | }] 139 | mocked.get( 140 | url, 141 | status=200, 142 | payload=[{ 143 | 'identifier': auth0_client.api_id, 144 | 'scopes': permissions, 145 | }], 146 | ) 147 | assert (await auth0_client.get_api_permissions()) == permissions 148 | mocked.get(url, status=400) 149 | auth0_client._cache.pop(('api_permissions',), None) 150 | with pytest.raises(Auth0Error): 151 | await auth0_client.get_api_permissions() 152 | mocked.get(url, status=200, payload=[]) 153 | with pytest.raises(Auth0Error): 154 | await auth0_client.get_api_permissions() 155 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import copy 3 | 4 | import graphene.test 5 | import pytest 6 | from graphql.execution.executors.asyncio import AsyncioExecutor 7 | 8 | import lektorium.repo 9 | import lektorium.schema 10 | from lektorium.app import error_formatter 11 | 12 | 13 | def deorder(obj): 14 | '''Removes OrderedDict's in object tree''' 15 | if isinstance(obj, (collections.OrderedDict, dict)): 16 | return {k: deorder(v) for k, v in obj.items()} 17 | elif isinstance(obj, list): 18 | return [deorder(k) for k in obj] 19 | return obj 20 | 21 | 22 | @pytest.fixture 23 | def client_with_permissions(): 24 | return graphene.test.Client( 25 | graphene.Schema( 26 | query=lektorium.schema.Query, 27 | mutation=lektorium.schema.MutationQuery, 28 | ), 29 | context={ 30 | 'repo': lektorium.repo.ListRepo( 31 | copy.deepcopy(lektorium.repo.SITES), 32 | ), 33 | 'user_permissions': [ 34 | 'user:ldi', 35 | ], 36 | }, 37 | executor=AsyncioExecutor(), 38 | ) 39 | 40 | 41 | @pytest.fixture 42 | def client_without_permissions(): 43 | return graphene.test.Client( 44 | graphene.Schema( 45 | query=lektorium.schema.Query, 46 | mutation=lektorium.schema.MutationQuery, 47 | ), 48 | context={ 49 | 'repo': lektorium.repo.ListRepo( 50 | copy.deepcopy(lektorium.repo.SITES), 51 | ), 52 | 'user_permissions': ['fake:permission'], 53 | }, 54 | executor=AsyncioExecutor(), 55 | format_error=error_formatter, 56 | ) 57 | 58 | 59 | @pytest.fixture 60 | def client_admin(): 61 | return graphene.test.Client( 62 | graphene.Schema( 63 | query=lektorium.schema.Query, 64 | mutation=lektorium.schema.MutationQuery, 65 | ), 66 | context={ 67 | 'repo': lektorium.repo.ListRepo( 68 | copy.deepcopy(lektorium.repo.SITES), 69 | ), 70 | 'user_permissions': [lektorium.schema.ADMIN], 71 | }, 72 | executor=AsyncioExecutor(), 73 | ) 74 | 75 | 76 | def test_admin_query(client_admin): 77 | result = client_admin.execute(r'''{ 78 | sites { 79 | siteId 80 | } 81 | }''') 82 | assert deorder(result) == { 83 | 'data': { 84 | 'sites': [ 85 | {'siteId': 'bow'}, 86 | {'siteId': 'uci'}, 87 | {'siteId': 'ldi'}, 88 | ], 89 | }, 90 | } 91 | 92 | 93 | def test_admin_mutation(client_admin): 94 | result = client_admin.execute(r'''mutation { 95 | createSite(siteId:"test" siteName:"test") { 96 | ok, 97 | } 98 | }''') 99 | assert deorder(result) == { 100 | 'data': { 101 | 'createSite': { 102 | 'ok': True, 103 | }, 104 | }, 105 | } 106 | 107 | 108 | @pytest.fixture 109 | def anonymous_client(): 110 | return graphene.test.Client( 111 | graphene.Schema( 112 | query=lektorium.schema.Query, 113 | mutation=lektorium.schema.MutationQuery, 114 | ), 115 | context={ 116 | 'repo': lektorium.repo.ListRepo( 117 | copy.deepcopy(lektorium.repo.SITES), 118 | ), 119 | 'user_permissions': [], 120 | }, 121 | executor=AsyncioExecutor(), 122 | format_error=error_formatter, 123 | ) 124 | 125 | 126 | def test_query_no_permissions(anonymous_client): 127 | result = anonymous_client.execute(r'''{ 128 | sites { 129 | siteId 130 | } 131 | }''') 132 | assert result['errors'][0]['code'] == 403 133 | assert not result['data']['sites'] 134 | 135 | 136 | def test_mutation_no_permissions(anonymous_client): 137 | result = anonymous_client.execute(r'''mutation { 138 | createSite(siteId:"test" siteName:"test") { 139 | ok, 140 | } 141 | }''') 142 | assert result['errors'][0]['code'] == 403 143 | assert not result['data']['createSite'] 144 | 145 | 146 | def test_query_with_permissions(client_with_permissions): 147 | result = client_with_permissions.execute(r'''{ 148 | sites { 149 | siteId 150 | } 151 | }''') 152 | assert deorder(result) == { 153 | 'data': { 154 | 'sites': [ 155 | {'siteId': 'ldi'}, 156 | ], 157 | }, 158 | } 159 | 160 | 161 | def test_query_without_permissions(client_without_permissions): 162 | result = client_without_permissions.execute(r'''{ 163 | sites { 164 | siteId 165 | } 166 | }''') 167 | assert deorder(result) == { 168 | 'data': { 169 | 'sites': [], 170 | }, 171 | } 172 | 173 | 174 | def test_mutation_with_permissions(client_with_permissions): 175 | result = client_with_permissions.execute(r'''mutation { 176 | createSession(siteId: "ldi") { 177 | ok, 178 | } 179 | }''') 180 | assert deorder(result) == { 181 | 'data': { 182 | 'createSession': { 183 | 'ok': True, 184 | }, 185 | }, 186 | } 187 | 188 | 189 | def test_mutation_without_permissions(client_without_permissions): 190 | result = client_without_permissions.execute(r'''mutation { 191 | createSite(siteId:"test" siteName:"test") { 192 | ok, 193 | } 194 | }''') 195 | assert result['errors'][0]['code'] == 403 196 | assert not result['data']['createSite'] 197 | -------------------------------------------------------------------------------- /tests/test_aws.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import boto3 4 | import pytest 5 | from botocore.stub import ANY, Stubber 6 | 7 | from lektorium.aws import AWS, BUCKET_POLICY_TEMPLATE 8 | 9 | 10 | def test_create_s3_bucket(): 11 | bucket_name = AWS.S3_PREFIX + 'foo' 12 | stub_response = {'ResponseMetadata': {'HTTPStatusCode': 200}} 13 | expected_params = {'Bucket': bucket_name} 14 | 15 | client = boto3.client('s3') 16 | stubber = Stubber(client) 17 | stubber.add_response('create_bucket', stub_response, expected_params) 18 | 19 | aws = AWS() 20 | aws.s3_client = client 21 | with stubber: 22 | response = aws.create_s3_bucket('foo') 23 | 24 | assert response == bucket_name 25 | 26 | 27 | def test_open_bucket_access(): 28 | bucket_name = AWS.S3_PREFIX + 'foo' 29 | stub_response = {'ResponseMetadata': {'HTTPStatusCode': 204}} 30 | expected_params_1 = {'Bucket': bucket_name} 31 | expected_params_2 = { 32 | 'Bucket': bucket_name, 33 | 'Policy': BUCKET_POLICY_TEMPLATE.format(bucket_name=bucket_name), 34 | } 35 | 36 | client = boto3.client('s3') 37 | stubber = Stubber(client) 38 | stubber.add_response( 39 | 'delete_public_access_block', 40 | stub_response, 41 | expected_params_1, 42 | ) 43 | stubber.add_response( 44 | 'put_bucket_policy', 45 | stub_response, 46 | expected_params_2, 47 | ) 48 | stubber.add_response( 49 | 'put_bucket_website', 50 | {'ResponseMetadata': {'HTTPStatusCode': 200}}, 51 | dict( 52 | Bucket=bucket_name, 53 | WebsiteConfiguration=dict( 54 | ErrorDocument=dict( 55 | Key='404.html', 56 | ), 57 | IndexDocument=dict( 58 | Suffix='index.html', 59 | ), 60 | ), 61 | ), 62 | ) 63 | 64 | aws = AWS() 65 | aws.s3_client = client 66 | with stubber: 67 | aws.open_bucket_access(bucket_name) 68 | 69 | 70 | def test_open_bucket_access_timeout(): 71 | bucket_name = AWS.S3_PREFIX + 'foo' 72 | stub_response = {'ResponseMetadata': {'HTTPStatusCode': 404}} 73 | expected_params_1 = {'Bucket': bucket_name} 74 | 75 | client = boto3.client('s3') 76 | stubber = Stubber(client) 77 | stubber.add_response( 78 | 'delete_public_access_block', 79 | stub_response, 80 | expected_params_1, 81 | ) 82 | 83 | aws = AWS() 84 | aws.s3_client = client 85 | aws.SLEEP_TIMEOUT = 0.01 86 | with pytest.raises(Exception): 87 | with stubber: 88 | aws.open_bucket_access(bucket_name) 89 | 90 | 91 | def test_create_cloudfront_distribution(): 92 | bucket_name = AWS.S3_PREFIX + 'foo' 93 | region = boto3.client('s3').meta.region_name 94 | origin_domain = f'{bucket_name}.s3-website-{region}.{AWS.S3_SUFFIX}' 95 | distribution_id = 'bar' 96 | domain_name = 'buzz' 97 | stub_response = { 98 | 'ResponseMetadata': {'HTTPStatusCode': 201}, 99 | 'Distribution': { 100 | 'Id': distribution_id, 101 | 'DomainName': domain_name, 102 | 'ARN': '', 103 | 'Status': '', 104 | 'LastModifiedTime': datetime.now(), 105 | 'InProgressInvalidationBatches': 1, 106 | 'ActiveTrustedSigners': {'Quantity': 0, 'Enabled': False}, 107 | 'DistributionConfig': { 108 | 'CallerReference': '', 109 | 'Origins': {'Quantity': 1, 'Items': [{ 110 | 'Id': '', 111 | 'DomainName': '', 112 | 'S3OriginConfig': {'OriginAccessIdentity': ''}, 113 | }]}, 114 | 'DefaultCacheBehavior': { 115 | 'TargetOriginId': '', 116 | 'ForwardedValues': { 117 | 'QueryString': False, 118 | 'Cookies': {'Forward': 'all'}, 119 | }, 120 | 'TrustedSigners': {'Quantity': 0, 'Enabled': False}, 121 | 'ViewerProtocolPolicy': '', 122 | 'MinTTL': 1, 123 | }, 124 | 'Comment': '', 125 | 'Enabled': True, 126 | }, 127 | }, 128 | } 129 | 130 | expected_params = dict( 131 | DistributionConfig=dict( 132 | CallerReference=ANY, 133 | Comment=ANY, 134 | Enabled=True, 135 | Origins=dict( 136 | Quantity=1, 137 | Items=[dict( 138 | Id=ANY, 139 | DomainName=origin_domain, 140 | CustomOriginConfig=dict( 141 | HTTPPort=80, 142 | HTTPSPort=443, 143 | OriginProtocolPolicy='http-only', 144 | ), 145 | )], 146 | ), 147 | DefaultCacheBehavior=dict( 148 | TargetOriginId=ANY, 149 | ViewerProtocolPolicy='redirect-to-https', 150 | TrustedSigners=dict(Quantity=0, Enabled=False), 151 | ForwardedValues=dict( 152 | Cookies={'Forward': 'all'}, 153 | Headers=dict(Quantity=0), 154 | QueryString=False, 155 | QueryStringCacheKeys=dict(Quantity=0), 156 | ), 157 | MinTTL=ANY, 158 | ), 159 | ), 160 | ) 161 | 162 | client = boto3.client('cloudfront') 163 | stubber = Stubber(client) 164 | stubber.add_response('create_distribution', stub_response, expected_params) 165 | 166 | aws = AWS() 167 | aws.cloudfront_client = client 168 | with stubber: 169 | response = aws.create_cloudfront_distribution(bucket_name) 170 | 171 | assert response == (distribution_id, domain_name) 172 | -------------------------------------------------------------------------------- /src/lektorium/app.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import functools 3 | import json 4 | import logging 5 | import pathlib 6 | import tempfile 7 | from os import environ 8 | 9 | import aiohttp.web 10 | import aiohttp_graphql 11 | import graphene 12 | import pkg_resources 13 | from graphql.error import format_error as format_graphql_error 14 | from graphql.execution.executors.asyncio import AsyncioExecutor 15 | from spherical.dev.log import init_logging 16 | 17 | from . import proxy, repo, schema 18 | from .auth0 import Auth0Client, FakeAuth0Client 19 | from .jwt import GraphExecutionError, JWTMiddleware 20 | from .repo.local import ( 21 | AsyncDockerServer, 22 | AsyncDockerServerLectern, 23 | AsyncLocalServer, 24 | FakeServer, 25 | FileStorage, 26 | GitlabStorage, 27 | GitStorage, 28 | LocalLektor, 29 | ) 30 | from .utils import closer 31 | 32 | 33 | class BaseEnum(enum.Enum): 34 | @classmethod 35 | def get(cls, name): 36 | names = tuple(x.name for x in cls) 37 | if name not in names: 38 | cls_name, names = cls.__name__, ', '.join(names) 39 | msg = f'Wrong {cls_name} value "{name}" should be one of {names}' 40 | raise ValueError(msg) 41 | return cls[name] 42 | 43 | 44 | class RepoType(BaseEnum): 45 | LIST = enum.auto() 46 | LOCAL = enum.auto() 47 | 48 | 49 | class StorageType(BaseEnum): 50 | FILE = FileStorage 51 | GIT = GitStorage 52 | GITLAB = GitlabStorage 53 | 54 | 55 | class ServerType(BaseEnum): 56 | FAKE = FakeServer 57 | ASYNC = AsyncLocalServer 58 | DOCKER = AsyncDockerServer 59 | LECTERN = AsyncDockerServerLectern 60 | 61 | 62 | def create_app(repo_type=RepoType.LIST, auth='', repo_args=''): 63 | init_logging() 64 | auth0_client, auth0_options = None, None 65 | if auth: 66 | auth_attributes = ('domain', 'id', 'api', 'management-id', 'management-secret') 67 | auth_attributes = ('data-auth0-{}'.format(x) for x in auth_attributes) 68 | auth0_options = dict(zip(auth_attributes, auth.split(','))) 69 | if len(auth0_options) > 3: 70 | auth0_client = Auth0Client(auth0_options) 71 | 72 | if repo_type == RepoType.LIST: 73 | if repo_args: 74 | raise ValueError('LIST repo does not support arguments') 75 | lektorium_repo = repo.ListRepo(repo.SITES) 76 | if auth0_client is None: 77 | auth0_client = FakeAuth0Client() 78 | elif repo_type == RepoType.LOCAL: 79 | server_type, _, storage_config = repo_args.partition(',') 80 | storage_config, _, params = storage_config.partition(',') 81 | token, _, protocol = params.partition(',') 82 | server_type, _, options = server_type.partition(':') 83 | options = {k: v for k, v in (x.split('=') for x in options.split(':') if x)} 84 | server_type = ServerType.get(server_type or 'FAKE') 85 | server = server_type.value(**options) 86 | 87 | protocol = protocol or 'https' 88 | storage_config = storage_config or 'FILE' 89 | storage_type, _, storage_path = storage_config.partition('=') 90 | storage_class = StorageType.get(storage_type).value 91 | if not storage_path: 92 | storage_path = pathlib.Path(closer(tempfile.TemporaryDirectory())) 93 | storage_path = storage_class.init(storage_path) 94 | if storage_class is GitlabStorage: 95 | skip_aws = True if environ.get('LEKTORIUM_SKIP_AWS', '') == 'YES' else False 96 | storage = storage_class(storage_path, token, protocol, skip_aws) 97 | else: 98 | storage = storage_class(pathlib.Path(storage_path)) 99 | 100 | sessions_root = None 101 | if server_type in (ServerType.DOCKER, ServerType.LECTERN): 102 | sessions_root = pathlib.Path('/sessions') 103 | if not sessions_root.exists(): 104 | raise RuntimeError('/sessions not exists') 105 | 106 | lektorium_repo = repo.LocalRepo( 107 | storage, 108 | server, 109 | LocalLektor, 110 | sessions_root=sessions_root, 111 | ) 112 | else: 113 | raise ValueError(f'repo_type not supported {repo_type}') 114 | 115 | logging.getLogger('lektorium').info(f'Start with {lektorium_repo}') 116 | return init_app(lektorium_repo, auth0_options, auth0_client) 117 | 118 | 119 | async def log_application_ready(app): 120 | logging.getLogger('lektorium').info('Lektorium started') 121 | 122 | 123 | def error_formatter(error): 124 | formatted = format_graphql_error(error) 125 | if hasattr(error, 'original_error'): 126 | if isinstance(error.original_error, GraphExecutionError): 127 | formatted['code'] = error.original_error.code 128 | return formatted 129 | 130 | 131 | async def docker_handler(authorizer, request): 132 | _, permissions = await authorizer.info(request) 133 | if schema.ADMIN not in permissions: 134 | raise aiohttp.web.HTTPUnauthorized() 135 | await proxy.handler('/var/run/docker.sock', request) 136 | 137 | 138 | def init_app(repo, auth0_options=None, auth0_client=None): 139 | app = aiohttp.web.Application(handler_args={'max_field_size': 16394}) 140 | 141 | client_dir = pkg_resources.resource_filename(__name__, 'client') 142 | client_dir = pathlib.Path(client_dir).resolve() 143 | 144 | async def index(request): 145 | return aiohttp.web.FileResponse(client_dir / 'public' / 'index.html') 146 | 147 | async def auth0_config(request): 148 | options = ( 149 | {x: auth0_options.get(f'data-auth0-{x}', None) for x in ['domain', 'id', 'api']} if auth0_options else {} 150 | ) 151 | options = json.dumps(options) 152 | return aiohttp.web.Response( 153 | text=f'let lektoriumAuth0Config={options};', 154 | content_type='application/javascript', 155 | ) 156 | 157 | app.router.add_route('*', '/', index) 158 | app.router.add_route('*', '/callback', index) 159 | app.router.add_route('*', '/logs', index) 160 | app.router.add_route('*', '/profile', index) 161 | app.router.add_route('GET', '/auth0-config', auth0_config) 162 | app.router.add_static('/components', client_dir / 'components') 163 | app.router.add_static('/images', client_dir / 'images') 164 | app.router.add_static('/scripts', client_dir / 'scripts') 165 | 166 | middleware = [] 167 | if auth0_options is not None: 168 | authorizer = JWTMiddleware(auth0_options['data-auth0-domain']) 169 | middleware.append(authorizer) 170 | app.router.add_route( 171 | 'GET', 172 | '/docker', 173 | functools.partial(docker_handler, authorizer), 174 | ) 175 | 176 | aiohttp_graphql.GraphQLView.attach( 177 | app, 178 | schema=graphene.Schema( 179 | query=schema.Query, 180 | mutation=schema.MutationQuery, 181 | ), 182 | middleware=middleware, 183 | graphiql=True, 184 | executor=AsyncioExecutor(), 185 | context=dict( 186 | repo=repo, 187 | auth0_client=auth0_client, 188 | **({'user_permissions': ['admin']} if auth0_options is None else {}), 189 | ), 190 | error_formatter=error_formatter, 191 | ) 192 | 193 | app.on_startup.append(log_application_ready) 194 | 195 | return app 196 | 197 | 198 | def main(repo_type='', auth=''): 199 | repo_type, _, repo_args = repo_type.partition(':') 200 | aiohttp.web.run_app( 201 | create_app( 202 | RepoType.get(repo_type), 203 | auth, 204 | repo_args, 205 | ), 206 | port=8000, 207 | ) 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://circleci.com/gh/sphericalpm/lektorium) 2 | [](https://codecov.io/gh/sphericalpm/lektorium) 3 | 4 | # What is Lektorium? 5 | Lektorium is a web content management solution for those with many little and/or similar websites. 6 | A typical user would be a large and loosely governed organisation with departments having a strong mandate to 7 | communicate externally and independently publish on the web. 8 | 9 | We will use Lektor (https://github.com/lektor/lektor), a great static site generator, as the source of basic content management and organisation functionality, and augment it with everything necessary for the business setting described above. 10 | 11 | # Design goals 12 | If you are responsible for the web presence, corporate branding, design and consistency in communications in an organisation 13 | described above - may the Universe afford you the best of luck, for keeping such arrangements from becoming an uncontrollable 14 | zoo of technologies and designs is not easy. Lektorium should help you address some of the related pains. Our plan is: 15 | 16 | ## 1. Separate "nerds" and "dummies" 17 | Some users will believe that they need to modify templates, create their own CSS and JavaScript. This is totally fine. 18 | These _nerds_ will have direct access to the _theme_ repository and will be made responsible for the changes they make. 19 | Other users will only ever need to change content. These _dummies_ will have a friendly user interface that will guide them. 20 | 21 | ## 2. All state in the repository 22 | The only configuration or state living outside the repository is the location and credentials for the repository. 23 | **All** other state, config, content or anything that is expected to survive unplugging of the machine is stored in the code repository. 24 | Multi-user scenarios, such as collaborative content authoring, will be resolved using repository means even if _dummies_ are involved. 25 | In case of conflicts, a _nerd_ will be called in. 26 | 27 | ## 3. No server-side code 28 | Server-side code is only interacted with using XHRs and it always speaks data, never presentation or user interaction. 29 | I.e. server side code will never be redirected to, produce HTML code or responses to be interpreted by the browser 30 | (as opposed to the users' JavaScript code). 31 | 32 | Server-side code is not a concern of Lektorium. Related attack vectors are made completely impossible because everything 33 | we are dealing with is static. Server resource issues are gone because all code is executed on the client. 34 | 35 | ## 4. Service administrators should not have to be nerds 36 | It should be possible to create and publish new websites, and carry out other site lifecycle using simple web interfaces. 37 | 38 | ## 5. Other than Lektor, we don't have other loyalties 39 | Lektorium should be resonably easy to teach to work with various webservers, repositories, DNSes, clouds... 40 | 41 | # What will be in the box / how will it work 42 | 43 | The basic architecture idea is that we create two things: 44 | 45 | - A "provisioning portal" where admins will set up which sites we have at all, who can work on their content, what 46 | they will be called for the outside world etc. 47 | - A hosted authoring system that will create pre-configured Lektor instances for unsophisticated content authors 48 | - A plugin API to turn sites' configuration stored in a VCS into configuration of whatever necessary to host these 49 | sites (web server, DNS zone, S3 buckets, 0auth,... etc.) 50 | 51 | 52 |  53 | 54 | Admins will add a site using the provisioning portal. The portal will create a repo for the site, grant access and trigger 55 | update of whatever configs that need to be updated via the config plugins. 56 | 57 | Nerds will be granted access to the site's repo directly. 58 | 59 | Dummies will be restricted to the use of hosted authoring. All changes from the hosted authoring will be committed into 60 | the VCS and picked up for promotion to whatever environment by the CI. 61 | 62 | 63 | --- 64 | # Setting up automatic Lektor deployment into AWS infrastructure using gitlab CI/CD pipelines 65 | 66 | ## Prepare AWS 67 | 68 | ### Create S3 bucket and Cloudfront 69 | 70 | This step will be automated in lektorium 71 | 72 | A guide can be used to create S3 [bucket](https://docs.aws.amazon.com/AmazonS3/latest/dev/website-hosting-custom-domain-walkthrough.html) and [cloudfront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Introduction.html#HowCloudFrontWorksOverview) 73 | Make note of bucket's and Cloudfront's names as they will be used in Lektor's config 74 | 75 | ### Create a user for S3 and Cloudfront access 76 | 77 | For deploying lektor to S3 there must exist a user whose credentials will be used 78 | 79 | * Open [IAM](https://console.aws.amazon.com/iam/home#/home) 80 | * Click on Users in the sidebar 81 | * Click "Add user" 82 | * Give user a name and "Programmatic access" 83 | * Give user access to S3 and Cloudfront (presets can be used) 84 | * Open newly created user and select "Security credentials" tab 85 | * Click Create Access Key 86 | * Click Show User Security Credentials 87 | 88 | ## Prepare gitlab 89 | 90 | ### Create environment 91 | 92 | * Open project 93 | * Go to Settings -> CI/CD 94 | * Add variable of type `File` and key `AWS_SHARED_CREDENTIALS_FILE` 95 | * The value of this variable should contain the following data (replace `KEY_ID` and `SECRET_KEY` with the ones from created AWS user): 96 | ``` 97 | [lektorium-aws-deploy] 98 | aws_access_key_id = 99 | aws_secret_access_key = 100 | ``` 101 | ### Make sure to have a runner 102 | 103 | Gitlab instance should have a runner [installed](https://docs.gitlab.com/runner/install/) to execute pipeline jobs. 104 | 105 | It is convenient to use docker for a runner. A docker runner can be created with a command: 106 | `docker run --rm -t -i -v gitlab-runner-config:/etc/gitlab-runner gitlab/gitlab-runner register` 107 | This will launch a wizard that will [register](https://docs.gitlab.com/runner/register/) a runner. 108 | Recommended options are: `alpine:latest` as image and `docker` as executor. 109 | 110 | After that, a docker container can be run with command: 111 | `docker run -d --restart=unless-stopped -v gitlab-runner-config:/etc/gitlab-runner -v /var/run/docker.sock:/var/run/docker.sock gitlab/gitlab-runner run` 112 | 113 | ## Update Lektor project 114 | 115 | ### Update lektor project file 116 | 117 | In Lektor config (`www.lektorproject`) add a section (substituning `NAME_OF_AWS_BUCKET` and `CLOUDFRONT_ID` with appropriate values) 118 | ``` 119 | [servers.lektorium-aws-deploy] 120 | name = Lektorium AWS Deploy 121 | enabled = yes 122 | target = s3:// 123 | cloudfront = 124 | ``` 125 | This will tell `lektor-s3` plugin where to deploy the project. 126 | 127 | ### Add gitlab ci file 128 | 129 | In the root of Lektorium project create a file `.gitlab-ci.yml` containing the following code: 130 | ```yaml 131 | lektorium-aws-deploy: 132 | variables: 133 | LC_ALL: C.UTF-8 134 | LANG: C.UTF-8 135 | AWS_PROFILE: lektorium-aws-deploy 136 | script: 137 | - apk add --update python3 python3-dev libffi-dev openssl-dev build-base 138 | - pip3 install --upgrade lektor 139 | - lektor plugins add lektor-s3 140 | - lektor build 141 | - lektor deploy "lektorium-aws-deploy" 142 | only: 143 | - master 144 | ``` 145 | Section `only` contains a name of the branch that should trigger deployment script when changed. 146 | This script will install lektor and lektor-s3 in a container, build the project and deploy it to AWS. 147 | -------------------------------------------------------------------------------- /src/lektorium/repo/memory.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Generator, Iterable, Mapping, Optional, Tuple 3 | 4 | import dateutil.parser 5 | 6 | from .interface import DuplicateEditSession, InvalidSessionState 7 | from .interface import Repo as BaseRepo 8 | from .interface import SessionNotFound 9 | 10 | 11 | class Session(dict): 12 | @property 13 | def edit_url(self) -> Optional[str]: 14 | return self.get('edit_url', None) 15 | 16 | @property 17 | def preview_url(self) -> Optional[str]: 18 | return self.get('preview_url', None) 19 | 20 | @property 21 | def legacy_admin_url(self) -> Optional[str]: 22 | return self.get('legacy_admin_url', None) 23 | 24 | 25 | VALID_MERGE_REQUEST = { 26 | 'id': 123, 27 | 'source_branch': 'test_branch', 28 | 'state': '1', 29 | 'target_branch': 'master', 30 | 'title': 'Request from "MJ" ', 31 | 'web_url': 'http://example.com/some/merge/request', 32 | 'created_at': dateutil.parser.parse('2020-04-22T06:39:54.918Z'), 33 | } 34 | 35 | 36 | SITES = [ 37 | { 38 | 'site_id': 'bow', 39 | 'site_name': 'Buy Our Widgets', 40 | 'production_url': 'https://bow.acme.com', 41 | 'staging_url': 'https://bow-test.acme.com', 42 | 'custodian': 'Max Jekov', 43 | 'custodian_email': 'mj@acme.com', 44 | 'sessions': [ 45 | Session(x) 46 | for x in [ 47 | { 48 | 'session_id': 'widgets-1', 49 | 'edit_url': 'https://cmsdciks.cms.acme.com', 50 | 'creation_time': dateutil.parser.parse('2019-07-19 10:18 UTC'), 51 | 'custodian': 'Max Jekov', 52 | 'custodian_email': 'mj@acme.com', 53 | } 54 | ] 55 | ], 56 | 'releasing': [ 57 | { 58 | 'site_name': 'Buy Our Widgets', 59 | **VALID_MERGE_REQUEST, 60 | } 61 | ], 62 | }, 63 | { 64 | 'site_id': 'uci', 65 | 'site_name': 'Underpants Collectors International', 66 | 'production_url': 'https://uci.com', 67 | 'staging_url': 'https://uci-staging.acme.com', 68 | 'custodian': 'Mikhail Vartanyan', 69 | 'custodian_email': 'mv@acme.com', 70 | 'sessions': [ 71 | Session(x) 72 | for x in [ 73 | { 74 | 'session_id': 'pantssss', 75 | 'creation_time': dateutil.parser.parse('2019-07-18 11:33 UTC'), 76 | 'custodian': 'Brian', 77 | 'custodian_email': 'brian@splitter.il', 78 | 'parked_time': dateutil.parser.parse('2019-07-18 11:53 UTC'), 79 | }, 80 | { 81 | 'session_id': 'pantss1', 82 | 'creation_time': dateutil.parser.parse('2019-07-18 11:34 UTC'), 83 | 'custodian': 'Muen', 84 | 'custodian_email': 'muen@flicker.tr', 85 | 'parked_time': dateutil.parser.parse('2019-07-18 11:54 UTC'), 86 | }, 87 | ] 88 | ], 89 | }, 90 | { 91 | 'site_id': 'ldi', 92 | 'site_name': 'Liver Donors Inc.', 93 | 'production_url': 'https://liver.do', 94 | 'staging_url': 'https://pancreas.acme.com', 95 | 'custodian': 'Brian', 96 | 'custodian_email': 'brian@splitter.il', 97 | }, 98 | ] 99 | 100 | 101 | class Repo(BaseRepo): 102 | def __init__(self, data: Mapping) -> None: 103 | self.data = data 104 | 105 | async def init_sessions(self): 106 | pass 107 | 108 | @property 109 | def sites(self) -> Iterable: 110 | yield from self.data 111 | 112 | @property 113 | def sessions(self) -> Mapping: 114 | return {session['session_id']: (session, site) for site in self.data for session in site.get('sessions', ())} 115 | 116 | @property 117 | def parked_sessions(self) -> Generator: 118 | yield from filter( 119 | lambda s: not bool(s[0].get('edit_url', None)), 120 | self.sessions.values(), 121 | ) 122 | 123 | @property 124 | def releasing(self): 125 | for site in self.data: 126 | yield from site.get('releasing', []) 127 | 128 | def create_session( 129 | self, 130 | site_id: str, 131 | themes: Optional[Tuple[str]] = None, 132 | custodian: Optional[Tuple[str, str]] = None, 133 | ) -> str: 134 | custodian_name, custodian_email = custodian or self.DEFAULT_USER 135 | site = {x['site_id']: x for x in self.sites}[site_id] 136 | if any(s.get('edit_url', None) for s in site.get('sessions', ())): 137 | raise DuplicateEditSession() 138 | session_id = self.generate_session_id() 139 | site.setdefault('sessions', []).append( 140 | Session( 141 | session_id=session_id, 142 | edit_url=f'https://edit.{session_id}-created.example.com', 143 | preview_url=None, 144 | legacy_admin_url=None, 145 | creation_time=datetime.datetime.now(), 146 | custodian=custodian_name, 147 | custodian_email=custodian_email, 148 | ) 149 | ) 150 | return session_id 151 | 152 | def destroy_session(self, session_id: str) -> None: 153 | if session_id not in self.sessions: 154 | raise SessionNotFound() 155 | site = self.sessions[session_id][1] 156 | site['sessions'] = [x for x in site['sessions'] if x['session_id'] != session_id] 157 | 158 | def park_session(self, session_id: str) -> None: 159 | if session_id not in self.sessions: 160 | raise SessionNotFound() 161 | session = self.sessions[session_id][0] 162 | if session.pop('edit_url', None) is None: 163 | raise InvalidSessionState() 164 | session['parked_time'] = datetime.datetime.now() 165 | 166 | def unpark_session(self, session_id: str) -> None: 167 | if session_id not in self.sessions: 168 | raise SessionNotFound() 169 | session, site = self.sessions[session_id] 170 | if session.get('edit_url', None) is not None: 171 | raise InvalidSessionState() 172 | if any(s.get('edit_url', None) for s in site.get('sessions', ())): 173 | raise DuplicateEditSession() 174 | edit_url = f'https://{session_id}-unparked.example.com' 175 | session['edit_url'] = edit_url 176 | session.pop('parked_time', None) 177 | 178 | async def create_site(self, site_id, name, owner=None): 179 | owner, email = owner or self.DEFAULT_USER 180 | self.data.append( 181 | dict( 182 | site_id=site_id, 183 | site_name=name, 184 | production_url=f'https://{site_id}.example.com', 185 | staging_url=f'https://staging.{site_id}.example.com', 186 | custodian=owner, 187 | custodian_email=email, 188 | ) 189 | ) 190 | 191 | def request_release(self, session_id): 192 | if session_id not in self.sessions: 193 | raise SessionNotFound() 194 | session, _ = self.sessions[session_id] 195 | if session.pop('edit_url', None) is None: 196 | raise InvalidSessionState() 197 | for site in self.data: 198 | site_sessions = [session['session_id'] for session in site.get('sessions', ())] 199 | if session['session_id'] in site_sessions: 200 | release = { 201 | 'site_name': site['site_name'], 202 | **VALID_MERGE_REQUEST, 203 | } 204 | release['source_branch'] = session_id 205 | site.setdefault('releasing', []).append(release) 206 | site['sessions'] = [session for session in site['sessions'] if session['session_id'] != session_id] 207 | 208 | def __repr__(self): 209 | qname = f'{self.__class__.__module__}.{self.__class__.__name__}' 210 | return f'{qname}({self.data})' 211 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import subprocess 4 | import webbrowser 5 | 6 | from invoke import task 7 | from invoke.tasks import call 8 | from spherical.dev.tasks import clean, dev, flake, isort, test # noqa: F401 9 | from spherical.dev.utils import flatten_options, named_args 10 | 11 | 12 | CONTAINERS_BASE = 'containers' 13 | IMAGE = 'lektorium' 14 | CONTAINER = IMAGE 15 | LEKTOR_BASE = PROXY_BASE = 'alpine' 16 | LEKTOR_IMAGE = f'{IMAGE}-lektor' 17 | LECTERN_IMAGE = f'{IMAGE}-lectern' 18 | PROXY_IMAGE = f'{IMAGE}-proxy' 19 | PROXY_CONTAINER = PROXY_IMAGE 20 | 21 | 22 | def get_config(ctx, env, cfg, auth, network): 23 | if env is None: 24 | env = named_args('-e ', ctx.get('env', {})) 25 | if cfg is None: 26 | cfg = ctx['cfg'] 27 | cfg = f'{cfg["repo"]}:{",".join(cfg["server"])}' 28 | if auth is None: 29 | auth = ctx.get('auth', None) 30 | if auth is not None: 31 | auth = ','.join(auth) 32 | network = network or ctx.get('network') 33 | return env, cfg, auth, network 34 | 35 | 36 | def get_skip_resolver(ctx): 37 | return ctx['env'].get('LEKTORIUM_SKIP_RESOLVER', False) 38 | 39 | 40 | @task 41 | def build_lektor_image(ctx): 42 | lektor_dir = f'{CONTAINERS_BASE}/lektor' 43 | ctx.run(f'docker build --build-arg BASE_IMAGE={LEKTOR_BASE} --tag {LEKTOR_IMAGE} {lektor_dir}') 44 | 45 | 46 | @task 47 | def build_lectern_image(ctx): 48 | lektor_dir = f'{CONTAINERS_BASE}/lectern' 49 | lectern_pkg = ctx['env'].get('LECTERN_PKG', '') 50 | if not lectern_pkg: 51 | return 52 | lectern_pkg_arg = f'--build-arg LECTERN_PKG="{lectern_pkg}" ' 53 | extra_index = ctx['env'].get('PIP_EXTRA_INDEX_URL', '') 54 | extra_index_arg = f'--build-arg PIP_EXTRA_INDEX_URL={extra_index} ' if extra_index else '' 55 | ctx.run( 56 | f'docker build --build-arg BASE_IMAGE={LEKTOR_BASE} ' 57 | f'{lectern_pkg_arg}{extra_index_arg}--tag {LECTERN_IMAGE} {lektor_dir}' 58 | ) 59 | 60 | 61 | @task 62 | def build_nginx_image(ctx, server_name=None): 63 | if server_name is None: 64 | server_name = ctx['server-name'] 65 | proxy_dir = f'{CONTAINERS_BASE}/nginx-proxy' 66 | ctx.run( 67 | 'docker build ' 68 | f'--build-arg BASE_IMAGE={PROXY_BASE} ' 69 | f'--build-arg SERVER_NAME={server_name} ' 70 | f'--tag {PROXY_IMAGE} {proxy_dir}' 71 | ) 72 | 73 | 74 | def lektorium_labels(server_name, port=80, skip_resolver=False): 75 | labels = { 76 | 'enable': 'true', 77 | 'http.services.lektorium.loadbalancer.server.port': f'{port}', 78 | 'http.routers': { 79 | 'lektorium': { 80 | 'entrypoints': 'websecure', 81 | 'rule': f"Host(`{server_name}`)", 82 | 'tls': {}, 83 | **({} if skip_resolver else {'tls.certresolver': 'le'}), 84 | }, 85 | }, 86 | } 87 | return named_args("--label ", flatten_options(labels, "traefik")) 88 | 89 | 90 | @task 91 | def run_nginx(ctx, network=None): 92 | *_, network = get_config(ctx, None, None, None, network) 93 | ctx.run(f'docker kill {PROXY_CONTAINER}', warn=True) 94 | ctx.run(f'docker rm {PROXY_CONTAINER}', warn=True) 95 | labels = lektorium_labels( 96 | ctx["server-name"], 97 | skip_resolver=get_skip_resolver(ctx), 98 | ) 99 | ctx.run(f'docker run -d --restart unless-stopped --net {network} --name {PROXY_CONTAINER} {labels} {PROXY_IMAGE} ') 100 | 101 | 102 | @task 103 | def run_traefik(ctx, image='traefik', ip=None, network=None): 104 | if ctx.get('skip-traefik', False): 105 | return 106 | 107 | env, *_, network = get_config(ctx, None, None, None, network) 108 | ctx.run(f'docker kill {PROXY_CONTAINER}', warn=True) 109 | ctx.run(f'docker rm {PROXY_CONTAINER}', warn=True) 110 | resolver = '' 111 | cert_options = '' 112 | volumes = '' 113 | if get_skip_resolver(ctx): 114 | volumes = ( 115 | '-v ./certs/:/etc/certs ' 116 | f'-v {ctx["dev-certs"]["chain"]}:/etc/certs/cert.pem ' 117 | f'-v {ctx["dev-certs"]["key"]}:/etc/certs/key.pem ' 118 | ) 119 | cert_options = '--entrypoints.websecure.http.tls=true --providers.file.filename=/etc/certs/traefik-config.yml ' 120 | else: 121 | volumes = '-v traefik-letsencrypt:/letsencrypt ' 122 | resolver = flatten_options( 123 | ctx['certificate-resolver'], 124 | 'certificatesresolvers.le.acme', 125 | ) 126 | resolver = named_args('--', resolver) 127 | 128 | labels = { 129 | 'enable': 'true', 130 | 'http': { 131 | 'middlewares': { 132 | 'redirect-to-https.redirectscheme.scheme': 'https', 133 | }, 134 | 'routers': { 135 | 'redirect-to-https': { 136 | 'entrypoints': 'web', 137 | 'rule': 'HostRegexp(`{host:.+}`)', 138 | 'middlewares': 'redirect-to-https', 139 | }, 140 | }, 141 | }, 142 | } 143 | labels = named_args('--label ', flatten_options(labels, 'traefik')) 144 | 145 | command = ' '.join( 146 | [ 147 | 'docker create', 148 | '--restart unless-stopped', 149 | f'--name {PROXY_CONTAINER}', 150 | '-v /var/run/docker.sock:/var/run/docker.sock', 151 | f'{volumes}', 152 | f'-p {ip or ""}{":" if ip else ""}80:80', 153 | f'-p {ip or ""}{":" if ip else ""}443:443', 154 | f'{env}', 155 | f'{labels}', 156 | f'{image}', 157 | '--accessLog', 158 | '--api.dashboard', 159 | f'{resolver}', 160 | '--entrypoints.web.address=:80', 161 | '--entrypoints.websecure.address=:443', 162 | '--log.level=DEBUG', 163 | '--log', 164 | '--providers.docker.exposedbydefault=false', 165 | f'{cert_options}', 166 | ] 167 | ) 168 | ctx.run(command) 169 | ctx.run(f'docker network create {network}', warn=True) 170 | ctx.run(f'docker network connect {network} {PROXY_CONTAINER}') 171 | ctx.run(f'docker start {PROXY_CONTAINER}') 172 | 173 | 174 | @task 175 | def build_server_image(ctx): 176 | if 'key' not in ctx: 177 | raise RuntimeError('pleas provde key for access server to gitlab') 178 | 179 | server_dir = f'{CONTAINERS_BASE}/server' 180 | with (pathlib.Path(server_dir) / 'key').open('w') as key_file: 181 | key = os.linesep.join( 182 | ( 183 | '-----BEGIN RSA PRIVATE KEY-----', 184 | *ctx['key'], 185 | '-----END RSA PRIVATE KEY-----', 186 | '', 187 | ) 188 | ) 189 | key_file.write(key) 190 | ctx.run(f'rm {server_dir}/lektorium*.whl', warn=True) 191 | ctx.run(f'pip wheel -w {server_dir} --no-deps .') 192 | ctx.run((f'docker build --tag {IMAGE} -f {server_dir}/Dockerfile.ubuntu {server_dir}')) 193 | 194 | 195 | @task(build_lectern_image, build_lektor_image, build_server_image) 196 | def build(ctx): 197 | pass 198 | 199 | 200 | @task 201 | def local(ctx, cfg=None, auth=None): 202 | _, cfg, auth, _ = get_config(ctx, None, cfg, auth, None) 203 | ctx.run(f'python -m lektorium {cfg} {auth}') 204 | 205 | 206 | @task 207 | def run( 208 | ctx, 209 | create_options='--restart unless-stopped', 210 | start_options=None, 211 | env=None, 212 | cfg=None, 213 | auth=None, 214 | network=None, 215 | ): 216 | env, cfg, auth, network = get_config(ctx, env, cfg, auth, network) 217 | ctx.run(f'docker stop {CONTAINER}', warn=True) 218 | ctx.run(f'docker kill {CONTAINER}', warn=True) 219 | ctx.run(f'docker rm {CONTAINER}', warn=True) 220 | labels = lektorium_labels(ctx["server-name"], 8000, get_skip_resolver(ctx)) 221 | ctx.run( 222 | f'docker create {create_options} {env} ' 223 | f'--name {CONTAINER} ' 224 | f'--net {network} ' 225 | f'-v lektorium-sessions:/sessions ' 226 | f'-v /var/run/docker.sock:/var/run/docker.sock ' 227 | f'{labels} ' 228 | f'{IMAGE} ' 229 | f'{cfg} "{auth}"' 230 | ) 231 | ctx.run(f'docker network create {network}', warn=True) 232 | ctx.run(f'docker network connect {network} {CONTAINER}') 233 | ctx.run(f'docker start {start_options or ""} {CONTAINER}') 234 | 235 | 236 | @task(build, run_traefik, run) 237 | def deploy(ctx): 238 | pass 239 | 240 | 241 | @task 242 | def debug(ctx, env=None, cfg=None, auth=None, network=None): 243 | env, cfg, auth, network = get_config(ctx, env, cfg, auth, network) 244 | call( 245 | run, 246 | create_options='-ti --rm', 247 | start_options='-i', 248 | env=env, 249 | cfg=cfg, 250 | auth=auth, 251 | network=network, 252 | ) 253 | 254 | 255 | @task 256 | def list(ctx): 257 | proc = subprocess.Popen( 258 | 'python -u -m lektorium LIST', 259 | stdout=subprocess.PIPE, 260 | stderr=subprocess.STDOUT, 261 | shell=True, 262 | ) 263 | target = None 264 | for line in proc.stdout: 265 | print(line.decode().rstrip()) 266 | if b'Running on' in line: 267 | target = line[20:-10].decode() 268 | break 269 | if target is not None: 270 | webbrowser.open(target) 271 | try: 272 | proc.communicate() 273 | except Exception: 274 | proc.kill() 275 | proc.wait() 276 | -------------------------------------------------------------------------------- /src/lektorium/repo/local/repo.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections.abc 3 | import functools 4 | import pathlib 5 | import shutil 6 | import tempfile 7 | from datetime import datetime 8 | 9 | from cached_property import cached_property 10 | 11 | from ...utils import closer 12 | from ..interface import DuplicateEditSession, InvalidSessionState 13 | from ..interface import Repo as BaseRepo 14 | from ..interface import SessionNotFound 15 | from .objects import Session, Site 16 | 17 | 18 | class FilteredDict(collections.abc.Mapping): 19 | def __init__(self, keys, dct): 20 | self.__keys = keys 21 | self.__dct = dct 22 | 23 | def __getitem__(self, key): 24 | if key not in self.__keys: 25 | raise KeyError(key) 26 | return self.__dct[key] 27 | 28 | @property 29 | def __common_keys(self): 30 | return set(self.__keys).intersection(self.__dct) 31 | 32 | def __iter__(self): 33 | return iter(self.__common_keys) 34 | 35 | def __len__(self): 36 | return len(self.__common_keys) 37 | 38 | 39 | class FilteredMergeRequestData(FilteredDict): 40 | MERGE_REQUEST_KEYS = [ 41 | 'id', 42 | 'source_branch', 43 | 'state', 44 | 'target_branch', 45 | 'title', 46 | 'web_url', 47 | 'created_at', 48 | ] 49 | 50 | def __init__(self, dct): 51 | super().__init__(self.MERGE_REQUEST_KEYS, dct) 52 | 53 | 54 | class Repo(BaseRepo): 55 | def __init__(self, storage, server, lektor, sessions_root=None): 56 | self.storage = storage 57 | self.server = server 58 | self.lektor = lektor 59 | if sessions_root is None: 60 | sessions_root = closer(tempfile.TemporaryDirectory()) 61 | self.sessions_root = pathlib.Path(sessions_root) 62 | self.sessions_initialized = False 63 | self.init_sites() 64 | 65 | def init_sites(self): 66 | for site_id, site in self.config.items(): 67 | site_dir = self.sessions_root / site_id 68 | if site_dir.exists(): 69 | for session_dir in site_dir.iterdir(): 70 | session = Session( 71 | session_id=session_dir.name, 72 | creation_time=datetime.fromtimestamp(session_dir.lstat().st_ctime), 73 | custodian=site['owner'], 74 | custodian_email=site['email'], 75 | parked_time=datetime.fromtimestamp(session_dir.lstat().st_mtime), 76 | edit_url=None, 77 | preview_url=None, 78 | legacy_admin_url=None, 79 | ) 80 | site.sessions[session['session_id']] = session 81 | 82 | async def init_sessions(self): 83 | if not self.sessions_initialized: 84 | sessions = self.server.sessions 85 | if asyncio.iscoroutine(sessions): 86 | sessions = await sessions 87 | for session in sessions: 88 | session = dict(session) 89 | site_id = session.pop('site_id', None) 90 | if site_id is None or site_id not in self.config: 91 | continue 92 | session = Session(session) 93 | session_id = session['session_id'] 94 | self.config[site_id].sessions[session_id] = session 95 | self.sessions_initialized = True 96 | 97 | @cached_property 98 | def config(self): 99 | return self.storage.config 100 | 101 | @property 102 | def sites(self): 103 | yield from self.config.values() 104 | 105 | @property 106 | def sessions(self): 107 | def iterate(): 108 | for site in self.config.values(): 109 | for session_id, session in site.sessions.items(): 110 | yield session_id, (session, site) 111 | 112 | return dict(iterate()) 113 | 114 | @property 115 | def parked_sessions(self): 116 | for site in self.config.values(): 117 | for session in site.sessions.values(): 118 | if session.parked: 119 | yield session 120 | 121 | @property 122 | def releasing(self): 123 | if 'merge_requests' in self.storage.__dict__: 124 | del self.storage.__dict__['merge_requests'] 125 | if 'projects' in self.storage.__dict__: 126 | del self.storage.__dict__['projects'] 127 | for site_id, site in self.config.items(): 128 | for merge_request_data in self.storage.get_merge_requests(site_id): 129 | if merge_request_data['source_branch'].startswith('session-'): 130 | yield { 131 | 'site_name': site['name'], 132 | **FilteredMergeRequestData(merge_request_data), 133 | } 134 | 135 | def create_session(self, site_id, themes=None, custodian=None): 136 | custodian, custodian_email = custodian or self.DEFAULT_USER 137 | site = self.config[site_id] 138 | if any(not s.parked for s in site.sessions.values()): 139 | raise DuplicateEditSession() 140 | session_id = self.generate_session_id() 141 | session_dir = self.sessions_root / site_id / session_id 142 | self.storage.create_session(site_id, session_id, session_dir, themes=themes) 143 | session_object = Session( 144 | session_id=session_id, 145 | creation_time=datetime.now(), 146 | custodian=custodian, 147 | custodian_email=custodian_email, 148 | preview_url=None, 149 | legacy_admin_url=None, 150 | ) 151 | session_object['edit_url'] = self.server.serve_lektor( 152 | session_dir, 153 | { 154 | **session_object, 155 | 'site_id': site_id, 156 | }, 157 | ) 158 | site.sessions[session_id] = session_object 159 | return session_id 160 | 161 | def destroy_session(self, session_id): 162 | if session_id not in self.sessions: 163 | raise SessionNotFound() 164 | site = self.sessions[session_id][1] 165 | session_dir = self.sessions_root / site['site_id'] / session_id 166 | self.server.stop_server( 167 | session_dir, 168 | functools.partial(shutil.rmtree, session_dir), 169 | ) 170 | site.sessions.pop(session_id) 171 | 172 | def park_session(self, session_id): 173 | if session_id not in self.sessions: 174 | raise SessionNotFound() 175 | session, site = self.sessions[session_id] 176 | site_id = site['site_id'] 177 | session_dir = self.sessions_root / site_id / session_id 178 | if session.parked: 179 | raise InvalidSessionState() 180 | self.server.stop_server(session_dir) 181 | self.storage.save_session(site_id, session_id, session_dir) 182 | session['edit_url'] = None 183 | session['preview_url'] = None 184 | session['legacy_admin_url'] = None 185 | session['parked_time'] = datetime.now() 186 | 187 | def unpark_session(self, session_id): 188 | if session_id not in self.sessions: 189 | raise SessionNotFound() 190 | session, site = self.sessions[session_id] 191 | if not session.parked: 192 | raise InvalidSessionState() 193 | if any(not s.parked for s in site.sessions.values()): 194 | raise DuplicateEditSession() 195 | site_id = site['site_id'] 196 | session_dir = self.sessions_root / site_id / session_id 197 | self.storage.update_session(site_id, session_id, session_dir) 198 | session['edit_url'] = self.server.serve_lektor( 199 | session_dir, 200 | {**session, 'site_id': site_id}, 201 | ) 202 | session.pop('parked_time', None) 203 | 204 | async def create_site(self, site_id, name, themes=None, owner=None): 205 | owner, email = owner or self.DEFAULT_USER 206 | site_root, site_options = await self.storage.create_site( 207 | self.lektor, 208 | name, 209 | owner, 210 | site_id, 211 | themes, 212 | ) 213 | 214 | production_url = site_options.pop('production_url', None) 215 | if production_url is None: 216 | production_url = site_options.get('url', None) 217 | if production_url is None: 218 | production_url = self.server.serve_static(site_root) 219 | 220 | self.config[site_id] = Site( 221 | site_id, 222 | production_url, 223 | **dict( 224 | name=name, 225 | owner=owner, 226 | email=email, 227 | **site_options, 228 | ), 229 | ) 230 | 231 | def request_release(self, session_id): 232 | if session_id not in self.sessions: 233 | raise SessionNotFound() 234 | session, site = self.sessions[session_id] 235 | if session.parked: 236 | raise InvalidSessionState() 237 | site_id = site['site_id'] 238 | session_dir = self.sessions_root / site_id / session_id 239 | self.storage.request_release(site_id, session_id, session_dir) 240 | self.destroy_session(session_id) 241 | 242 | def __repr__(self): 243 | qname = f'{self.__class__.__module__}.{self.__class__.__name__}' 244 | return f'{qname}({self.storage}, {self.server}, {self.lektor})' 245 | -------------------------------------------------------------------------------- /tests/test_gitlab.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from unittest import mock 3 | from urllib.parse import parse_qsl, quote_plus 4 | 5 | import pytest 6 | 7 | from lektorium.repo.local.storage import GitLab 8 | from lektorium.repo.local.templates import ( 9 | AWS_SHARED_CREDENTIALS_FILE_TEMPLATE, 10 | EMPTY_COMMIT_PAYLOAD, 11 | ) 12 | 13 | 14 | def mock_namespaces(mocker, gitlab_instance): 15 | namespace_id = '2' 16 | mocker.get( 17 | f'{gitlab_instance.repo_url}/groups', 18 | request_headers=gitlab_instance.headers, 19 | json=[], 20 | ) 21 | mocker.get( 22 | f'{gitlab_instance.repo_url}/namespaces', 23 | request_headers=gitlab_instance.headers, 24 | json=[ 25 | {'path': 'fizzle', 'id': '1'}, 26 | {'path': gitlab_instance.options["namespace"], 'id': namespace_id}, 27 | {'path': 'fizzy', 'id': '3'}, 28 | ], 29 | ) 30 | return namespace_id 31 | 32 | 33 | def mock_projects(mocker, gitlab_instance): 34 | def match_first_page(request): 35 | params = dict(parse_qsl(request.query)) 36 | return params['page'] == '1' 37 | 38 | def match_other_page(request): 39 | params = dict(parse_qsl(request.query)) 40 | return params['page'] != '1' 41 | 42 | namespace = gitlab_instance.options["namespace"] 43 | namespace_quoted = quote_plus(namespace) 44 | mocker.get( 45 | f'{gitlab_instance.repo_url}/groups/{namespace_quoted}/projects', 46 | request_headers=gitlab_instance.headers, 47 | json=[ 48 | {'path_with_namespace': f'{namespace}/proj1', 'id': '1'}, 49 | {'path_with_namespace': 'other/proj1', 'id': '2'}, 50 | {'path_with_namespace': f'{namespace}/proj2', 'id': '3'}, 51 | ], 52 | additional_matcher=match_first_page, 53 | ) 54 | mocker.get( 55 | f'{gitlab_instance.repo_url}/groups/{namespace_quoted}/projects', 56 | request_headers=gitlab_instance.headers, 57 | json=[], 58 | additional_matcher=match_other_page, 59 | ) 60 | 61 | 62 | def test_repo_url(): 63 | repo_url = GitLab(dict( 64 | scheme='http', 65 | host='foo', 66 | namespace='fizz/buzz', 67 | )).repo_url 68 | assert repo_url == f'http://foo/api/{GitLab.DEFAULT_API_VERSION}' 69 | 70 | repo_url = GitLab(dict( 71 | scheme='https', 72 | host='bar', 73 | api_version='v2', 74 | namespace='fizz/buzz', 75 | )).repo_url 76 | assert repo_url == 'https://bar/api/v2' 77 | 78 | 79 | def test_path(): 80 | path = GitLab(dict(namespace='foo/fuu', project='bar')).path 81 | assert path == 'foo/fuu/bar' 82 | 83 | 84 | def test_headers(): 85 | headers = GitLab(dict(token='foo', namespace='fizz/buzz')).headers 86 | assert headers == {'Authorization': 'Bearer foo'} 87 | 88 | 89 | def test_project_id(requests_mock): 90 | options = dict( 91 | scheme='http', 92 | host='foo.bar', 93 | token='buzz', 94 | namespace='fizz/boop', 95 | project='proj1', 96 | ) 97 | gitlab = GitLab(options) 98 | 99 | mock_projects(requests_mock, gitlab) 100 | 101 | assert gitlab.project_id == '1' 102 | 103 | options['project'] = 'proj' 104 | gitlab = GitLab(options) 105 | 106 | with pytest.raises(ValueError): 107 | gitlab.project_id 108 | 109 | 110 | def test_get_namespace_id(requests_mock): 111 | gitlab = GitLab(dict( 112 | scheme='http', 113 | host='foo.bar', 114 | token='buzz', 115 | namespace='fizz', 116 | )) 117 | 118 | namespace_id = mock_namespaces(requests_mock, gitlab) 119 | 120 | assert gitlab.namespace_id == namespace_id 121 | 122 | 123 | def test_projects(requests_mock): 124 | gitlab = GitLab(dict( 125 | scheme='http', 126 | host='foo.bar', 127 | token='buzz', 128 | namespace='fizz/buzz', 129 | )) 130 | 131 | mock_projects(requests_mock, gitlab) 132 | 133 | assert len(gitlab.projects) == 3 134 | 135 | 136 | def test_create_new_project(requests_mock): 137 | options = dict( 138 | scheme='http', 139 | host='foo.bar', 140 | token='buzz', 141 | namespace='fizz', 142 | project='proj', 143 | branch='master', 144 | ) 145 | gitlab = GitLab(options) 146 | 147 | namespace_id = mock_namespaces(requests_mock, gitlab) 148 | 149 | requests_mock.post( 150 | ( 151 | f'{gitlab.repo_url}/projects?name=proj&namespace_id={namespace_id}' 152 | f'&visibility=private&default_branch={options["branch"]}' 153 | ), 154 | request_headers=gitlab.headers, 155 | json={'id': '50'}, 156 | ) 157 | 158 | response = gitlab._create_new_project() 159 | 160 | assert response.status_code == 200 161 | assert response.json()['id'] == '50' 162 | 163 | 164 | def test_create_project_variables(requests_mock): 165 | options = dict( 166 | scheme='http', 167 | host='foo.bar', 168 | token='buzz', 169 | namespace='fizz', 170 | project='proj1', 171 | ) 172 | gitlab = GitLab(options) 173 | 174 | key_id = 'key_id' 175 | secret_key = 'secret_key' 176 | environ['AWS_ACCESS_KEY_ID'] = key_id 177 | environ['AWS_SECRET_ACCESS_KEY'] = secret_key 178 | project_id = '1' 179 | value = AWS_SHARED_CREDENTIALS_FILE_TEMPLATE.format( 180 | aws_key_id=key_id, 181 | aws_secret_key=secret_key, 182 | ) 183 | aws_variable_name = gitlab.AWS_CREDENTIALS_VARIABLE_NAME 184 | 185 | mock_projects(requests_mock, gitlab) 186 | requests_mock.post( 187 | ( 188 | f'{gitlab.repo_url}/projects/{project_id}/variables?id={project_id}' 189 | f'&variable_type=file&key={aws_variable_name}&value={quote_plus(value)}' 190 | ), 191 | request_headers=gitlab.headers, 192 | ) 193 | 194 | response = gitlab._create_aws_project_variable() 195 | 196 | assert response.status_code == 200 197 | 198 | 199 | def test_create_initial_commit(requests_mock): 200 | def additional_matcher(request): 201 | return EMPTY_COMMIT_PAYLOAD in request.text 202 | 203 | options = dict( 204 | scheme='http', 205 | host='foo.bar', 206 | token='buzz', 207 | namespace='fizz', 208 | project='proj1', 209 | ) 210 | gitlab = GitLab(options) 211 | 212 | headers = gitlab.headers 213 | headers['Content-Type'] = 'application/json' 214 | project_id = '1' 215 | 216 | mock_projects(requests_mock, gitlab) 217 | requests_mock.post( 218 | f'{gitlab.repo_url}/projects/{project_id}/repository/commits', 219 | request_headers=headers, 220 | additional_matcher=additional_matcher, 221 | ) 222 | 223 | response = gitlab._create_initial_commit() 224 | 225 | assert response.status_code == 200 226 | 227 | 228 | @pytest.mark.asyncio 229 | async def test_init_project(requests_mock): 230 | new_proj_url = 'git@foo.bar:fizz/new_proj' 231 | 232 | def create_project(): 233 | mocked = mock.Mock() 234 | mocked.json = mock.MagicMock(return_value={'ssh_url_to_repo': new_proj_url}) 235 | return mocked 236 | 237 | options = dict( 238 | scheme='http', 239 | host='foo.bar', 240 | token='buzz', 241 | namespace='fizz', 242 | project='proj1', 243 | branch='master', 244 | ) 245 | gitlab = GitLab(options) 246 | 247 | mock_projects(requests_mock, gitlab) 248 | 249 | with pytest.raises(Exception): 250 | await gitlab.init_project() 251 | 252 | options['project'] = 'new_proj' 253 | gitlab = GitLab(options) 254 | 255 | with mock.patch.multiple( 256 | gitlab, 257 | _create_new_project=create_project, 258 | _create_aws_project_variable=lambda: None, 259 | _create_initial_commit=lambda: None, 260 | ): 261 | pp = await gitlab.init_project() 262 | 263 | assert pp == new_proj_url 264 | 265 | 266 | def test_merge_requests(requests_mock): 267 | src_branch = 'branch_source_abc' 268 | tgt_branch = 'branch_target_def' 269 | title = 'request title ghi' 270 | 271 | def additional_matcher(request): 272 | return all( 273 | item in request.text for item in (src_branch, tgt_branch, title) 274 | ) 275 | 276 | options = dict( 277 | scheme='http', 278 | host='foo.bar', 279 | token='buzz', 280 | namespace='fizz', 281 | project='proj1', 282 | branch='master', 283 | ) 284 | gitlab = GitLab(options) 285 | 286 | project_id = '1' 287 | 288 | mock_projects(requests_mock, gitlab) 289 | requests_mock.get( 290 | f'{gitlab.repo_url}/projects/{project_id}/merge_requests', 291 | request_headers=gitlab.headers, 292 | json=['a', 'b', 'c'], 293 | ) 294 | 295 | response = gitlab.merge_requests 296 | 297 | assert len(response) == 3 298 | 299 | 300 | def test_create_merge_request(requests_mock): 301 | src_branch = 'branch_source_abc' 302 | tgt_branch = 'branch_target_def' 303 | title = 'request-title-ghi' 304 | 305 | def additional_matcher(request): 306 | return all( 307 | item in request.text for item in (src_branch, tgt_branch, title) 308 | ) 309 | 310 | options = dict( 311 | scheme='http', 312 | host='foo.bar', 313 | token='buzz', 314 | namespace='fizz', 315 | project='proj1', 316 | branch='master', 317 | ) 318 | gitlab = GitLab(options) 319 | 320 | project_id = '1' 321 | 322 | mock_projects(requests_mock, gitlab) 323 | requests_mock.post( 324 | f'{gitlab.repo_url}/projects/{project_id}/merge_requests', 325 | request_headers=gitlab.headers, 326 | additional_matcher=additional_matcher, 327 | ) 328 | 329 | gitlab.create_merge_request( 330 | source_branch=src_branch, 331 | target_branch=tgt_branch, 332 | title=title, 333 | ) 334 | -------------------------------------------------------------------------------- /src/lektorium/auth0.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | import wrapt 5 | from aiohttp import ClientSession 6 | from cached_property import cached_property 7 | 8 | 9 | def cacher(method_alias, timeout=None): 10 | @wrapt.decorator 11 | async def wrapper(wrapped, instance, args, kwargs): 12 | if kwargs: 13 | raise Exception('Cannot use keyword args') 14 | key = (method_alias, *args) 15 | if key in instance._cache: 16 | _, cached_on = instance._cache[key] 17 | if timeout is not None and time.time() - cached_on > timeout: 18 | instance._cache.pop(key) 19 | if key not in instance._cache: 20 | instance._cache[key] = (await wrapped(*args), time.time()) 21 | result, _ = instance._cache[key] 22 | return result 23 | return wrapper 24 | 25 | 26 | class FakeAuth0Client: 27 | def __init__(self): 28 | self.token = 'test_token' 29 | self.users = [ 30 | {'user_id': 'test_id', 'name': 'Max Jekov', 'nickname': 'mj', 'email': 'mj@mail.m'}, 31 | ] 32 | self.users_permissions = { 33 | 'test_id': [{'permission_name': 'Test Permission1', 'description': ''}], 34 | } 35 | self.api_permissions = [ 36 | {'value': 'Test Permission1', 'description': ''}, 37 | {'value': 'Test Permission2', 'description': ''}, 38 | ] 39 | 40 | @property 41 | async def auth_token(self): 42 | return self.token 43 | 44 | async def get_users(self): 45 | for user in self.users: 46 | permissions = self.users_permissions.get(user['user_id']) 47 | user['permissions'] = [permission['permission_name'] for permission in permissions] 48 | return self.users 49 | 50 | async def get_user_permissions(self, user_id): 51 | result = self.users_permissions.get(user_id) 52 | if result is None: 53 | raise Auth0Error() 54 | return result 55 | 56 | async def set_user_permissions(self, user_id, permissions): 57 | if user_id in self.users_permissions: 58 | new_permissions = [ 59 | {'permission_name': name, 'description': ''} for name in permissions 60 | ] 61 | for permission in new_permissions: 62 | if permission not in self.users_permissions[user_id]: 63 | self.users_permissions[user_id].append(permission) 64 | return True 65 | raise Auth0Error 66 | 67 | async def delete_user_permissions(self, user_id, permissions): 68 | if user_id in self.users_permissions: 69 | permissions_to_delete = [ 70 | {'permission_name': name, 'description': ''} for name in permissions 71 | ] 72 | for index, permission in enumerate(self.users_permissions[user_id]): 73 | if permission in permissions_to_delete: 74 | del self.users_permissions[user_id][index] 75 | return True 76 | raise Auth0Error 77 | 78 | async def get_api_permissions(self): 79 | return self.api_permissions 80 | 81 | 82 | class ThrottledClientSession(ClientSession): 83 | DELAY = 0.6 84 | 85 | def __init__(self, *args, **kwargs): 86 | self.last_request_time = time.time() 87 | super().__init__(*args, **kwargs) 88 | 89 | @cached_property 90 | def lock(self): 91 | return asyncio.Lock() 92 | 93 | async def _request(self, *args, **kwargs): 94 | async with self.lock: 95 | sleep_time = self.last_request_time + self.DELAY - time.time() 96 | if sleep_time > 0: 97 | await asyncio.sleep(sleep_time) 98 | self.last_request_time = time.time() 99 | return await super()._request(*args, **kwargs) 100 | 101 | 102 | class Auth0Client: 103 | CACHE_VALID_PERIOD = 60 104 | CACHE_USERS_ALIAS = 'users' 105 | CACHE_USER_PERMISSIONS_ALIAS = 'user_permissions' 106 | CACHE_API_PERMISSIONS_ALIAS = 'api_permissions' 107 | 108 | def __init__(self, auth): 109 | self._cache = dict() 110 | self.base_url = f'https://{auth["data-auth0-domain"]}' 111 | self.token_url = f'{self.base_url}/oauth/token' 112 | self.api_id = auth['data-auth0-api'] 113 | self.audience = f'{self.base_url}/api/v2' 114 | self.data = { 115 | 'client_id': auth['data-auth0-management-id'], 116 | 'client_secret': auth['data-auth0-management-secret'], 117 | 'audience': f'{self.audience}/', 118 | 'grant_type': 'client_credentials', 119 | } 120 | self.token = None 121 | self.token_time = time.time() + self.CACHE_VALID_PERIOD 122 | 123 | @cached_property 124 | def session(self): 125 | return ThrottledClientSession() 126 | 127 | @property 128 | async def auth_token(self): 129 | if self.token is not None: 130 | if time.time() - self.token_time < self.CACHE_VALID_PERIOD: 131 | return self.token 132 | async with self.session.post(self.token_url, json=self.data) as resp: 133 | if resp.status != 200: 134 | self.token = None 135 | raise Auth0Error(f'Error {resp.status}') 136 | result = await resp.json() 137 | self.token = result['access_token'] 138 | self.token_time = time.time() 139 | return self.token 140 | 141 | @property 142 | async def auth_headers(self): 143 | headers = {'Authorization': 'Bearer {0}'.format(await self.auth_token)} 144 | return headers 145 | 146 | @cacher(CACHE_USERS_ALIAS, 300) 147 | async def get_users(self): 148 | params = {'fields': 'name,nickname,email,user_id', 'per_page': 100} 149 | url = f'{self.audience}/users' 150 | async with self.session.get(url, params=params, headers=await self.auth_headers) as resp: 151 | if resp.status != 200: 152 | raise Auth0Error(f'Error {resp.status}') 153 | users = await resp.json() 154 | for user in users: 155 | if user.get('user_id'): 156 | permissions = await self.get_user_permissions(user['user_id']) 157 | user['permissions'] = [permission['permission_name'] for permission in permissions] 158 | return users 159 | 160 | @cacher(CACHE_USER_PERMISSIONS_ALIAS) 161 | async def get_user_permissions(self, user_id): 162 | params = {'per_page': 100} 163 | url = f'{self.audience}/users/{user_id}/permissions' 164 | async with self.session.get(url, params=params, headers=await self.auth_headers) as resp: 165 | if resp.status != 200: 166 | raise Auth0Error(f'Error {resp.status}') 167 | return await resp.json() 168 | 169 | async def set_user_permissions(self, user_id, permissions): 170 | available_permissions = [x['value'] for x in await self.get_api_permissions()] 171 | for permission in set(permissions).difference(available_permissions): 172 | await self.add_api_permission(permission, permission) 173 | data = {'permissions': []} 174 | for permission in permissions: 175 | data['permissions'].append({ 176 | 'resource_server_identifier': self.api_id, 177 | 'permission_name': permission, 178 | }) 179 | url = f'{self.audience}/users/{user_id}/permissions' 180 | async with self.session.post(url, json=data, headers=await self.auth_headers) as resp: 181 | if resp.status != 201: 182 | raise Auth0Error(f'Error {resp.status}') 183 | self._cache.pop((self.CACHE_USER_PERMISSIONS_ALIAS, user_id), None) 184 | self._cache.pop((self.CACHE_USERS_ALIAS,), None) 185 | return True 186 | 187 | async def delete_user_permissions(self, user_id, permissions): 188 | self._cache.clear() 189 | data = {'permissions': []} 190 | for permission in permissions: 191 | data['permissions'].append({ 192 | 'resource_server_identifier': self.api_id, 193 | 'permission_name': permission, 194 | }) 195 | url = f'{self.audience}/users/{user_id}/permissions' 196 | async with self.session.delete(url, json=data, headers=await self.auth_headers) as resp: 197 | if resp.status != 204: 198 | raise Auth0Error(f'Error {resp.status}') 199 | self._cache.pop((self.CACHE_USER_PERMISSIONS_ALIAS, user_id), None) 200 | self._cache.pop((self.CACHE_USERS_ALIAS,), None) 201 | return True 202 | 203 | @cacher(CACHE_API_PERMISSIONS_ALIAS) 204 | async def get_api_permissions(self): 205 | params = {'per_page': 100} 206 | url = f'{self.audience}/resource-servers' 207 | async with self.session.get(url, params=params, headers=await self.auth_headers) as resp: 208 | if resp.status != 200: 209 | raise Auth0Error(f'Error {resp.status}') 210 | data = await resp.json() 211 | data = [x for x in data if x.get('identifier') == self.api_id] 212 | if not data: 213 | raise Auth0Error(f"'{self.api_id}' api was not found") 214 | result = data[0]['scopes'] 215 | return result 216 | 217 | async def add_api_permission(self, permission_name, description): 218 | data = { 219 | 'scopes': [ 220 | {'value': permission_name, 'description': description}, 221 | *(await self.get_api_permissions()), 222 | ], 223 | } 224 | url = f'{self.audience}/resource-servers/{self.api_id}' 225 | async with self.session.patch(url, headers=await self.auth_headers, json=data) as resp: 226 | if resp.status != 200: 227 | raise Auth0Error(f'Error {resp.status}') 228 | self._cache.pop((self.CACHE_API_PERMISSIONS_ALIAS,), None) 229 | return True 230 | 231 | 232 | class Auth0Error(Exception): 233 | pass 234 | -------------------------------------------------------------------------------- /src/lektorium/schema.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from asyncio import Future, iscoroutine 3 | 4 | import wrapt 5 | from graphene import ( 6 | Boolean, 7 | DateTime, 8 | Field, 9 | List, 10 | Mutation, 11 | ObjectType, 12 | String, 13 | ) 14 | 15 | import lektorium.repo 16 | from lektorium.auth0 import Auth0Error 17 | 18 | from .jwt import GraphExecutionError 19 | 20 | 21 | ADMIN = 'admin' 22 | 23 | 24 | class Site(ObjectType): 25 | def __init__(self, **kwargs): 26 | super().__init__(**{k: v for k, v in kwargs.items() if k in self._meta.fields}) 27 | 28 | site_id = String() 29 | site_name = String() 30 | custodian = String() 31 | custodian_email = String() 32 | production_url = String() 33 | staging_url = String() 34 | sessions = List(lambda: Session) 35 | 36 | 37 | class Session(ObjectType): 38 | session_id = String() 39 | site_name = String() 40 | edit_url = String() 41 | preview_url = String() 42 | legacy_admin_url = String() 43 | creation_time = DateTime() 44 | custodian = String() 45 | custodian_email = String() 46 | parked_time = DateTime() 47 | production_url = String() 48 | staging_url = String() 49 | site = Field(Site) 50 | parked = Boolean() 51 | themes = List(String) 52 | 53 | def resolve_production_url(self, info): 54 | return self.site.production_url 55 | 56 | def resolve_staging_url(self, info): 57 | return self.site.staging_url 58 | 59 | def resolve_site_name(self, info): 60 | return self.site.site_name 61 | 62 | def resolve_parked(self, info): 63 | return not bool(self.edit_url) 64 | 65 | 66 | class User(ObjectType): 67 | user_id = String() 68 | email = String() 69 | name = String() 70 | nickname = String() 71 | permissions = List(String) 72 | 73 | 74 | class Theme(ObjectType): 75 | name = String() 76 | active = Boolean() 77 | 78 | 79 | class Permission(ObjectType): 80 | permission_name = String() 81 | description = String() 82 | resource_server_name = String() 83 | resource_server_identifier = String() 84 | sources = String() 85 | 86 | 87 | class ApiPermission(ObjectType): 88 | value = String() 89 | description = String() 90 | 91 | 92 | class Releasing(ObjectType): 93 | site_id = String() 94 | site_name = String() 95 | title = String() 96 | id = String() 97 | target_branch = String() 98 | source_branch = String() 99 | state = String() 100 | web_url = String() 101 | created_at = DateTime() 102 | 103 | 104 | def skip_permissions_check(info): 105 | return info.context.get('skip_permissions_check', False) 106 | 107 | 108 | def repo(method): 109 | def wrapper(self, info, *args, **kwargs): 110 | repo = info.context['repo'] 111 | return method(self, info, *args, repo=repo, **kwargs) 112 | 113 | return wrapper 114 | 115 | 116 | class PermissionError(GraphExecutionError): 117 | def __init__(self): 118 | super().__init__('User has no permission', code=403) 119 | 120 | 121 | def get_user_permissions(info): 122 | permissions = set(info.context.get('user_permissions', [])) 123 | if not permissions: 124 | raise PermissionError() 125 | return permissions 126 | 127 | 128 | def inject_permissions(wrapped=None, admin=False): 129 | if wrapped is None: 130 | return functools.partial(inject_permissions, admin=admin) 131 | 132 | @wrapt.decorator 133 | async def wrapper(wrapped, instance, args, kwargs): 134 | info, *_ = args 135 | permissions = get_user_permissions(info) 136 | if skip_permissions_check(info): 137 | permissions = (ADMIN,) 138 | if admin and ADMIN not in permissions: 139 | return () 140 | return await wrapped(*args, permissions=permissions, **kwargs) 141 | 142 | return wrapper(wrapped) 143 | 144 | 145 | class Query(ObjectType): 146 | sites = List(Site) 147 | sessions = List(Session, parked=Boolean(default_value=False)) 148 | users = List(User) 149 | themes = List(Theme, site_id=String(default_value=None)) 150 | user_permissions = List(Permission, user_id=String()) 151 | available_permissions = List(ApiPermission) 152 | releasing = List(Releasing) 153 | 154 | @staticmethod 155 | def sessions_list(repo): 156 | for site in repo.sites: 157 | site = Site(**site) 158 | for session in site.sessions or (): 159 | themes = [] 160 | if bool(session.edit_url): 161 | session_dir = repo.sessions_root / site.site_id / session['session_id'] 162 | themes = repo.storage.config_dir_themes(session_dir) 163 | yield dict(**session, themes=themes, site=site) 164 | 165 | @inject_permissions 166 | @repo 167 | async def resolve_sites(self, info, repo, permissions): 168 | return [Site(**x) for x in repo.sites if ADMIN in permissions or f'user:{x["site_id"]}' in permissions] 169 | 170 | @inject_permissions 171 | @repo 172 | async def resolve_sessions(self, info, parked, repo, permissions): 173 | await repo.init_sessions() 174 | sessions = (Session(**x) for x in Query.sessions_list(repo)) 175 | return [ 176 | x 177 | for x in sessions 178 | if (bool(x.edit_url) != parked and (ADMIN in permissions or f'user:{x.site.site_id}' in permissions)) 179 | ] 180 | 181 | @inject_permissions(admin=True) 182 | async def resolve_users(self, info, permissions): 183 | auth0_client = info.context['auth0_client'] 184 | return [User(**x) for x in await auth0_client.get_users()] 185 | 186 | @repo 187 | def resolve_themes(self, info, site_id, repo): 188 | if site_id: 189 | return repo.storage.site_themes(site_id)[::-1] 190 | return [{'name': theme, 'active': True} for theme in repo.storage.themes().values()] 191 | 192 | @inject_permissions(admin=True) 193 | async def resolve_user_permissions(self, info, user_id, permissions): 194 | auth0_client = info.context['auth0_client'] 195 | return [Permission(**x) for x in await auth0_client.get_user_permissions(user_id)] 196 | 197 | @inject_permissions(admin=True) 198 | @repo 199 | async def resolve_available_permissions(self, info, repo, permissions): 200 | return [ 201 | ApiPermission(v, v) 202 | for v in ( 203 | 'admin', 204 | *(f'user:{x["site_id"]}' for x in repo.sites), 205 | ) 206 | ] 207 | 208 | @inject_permissions 209 | @repo 210 | async def resolve_releasing(self, info, repo, permissions): 211 | repo = info.context['repo'] 212 | return [Releasing(**x) for x in repo.releasing] 213 | 214 | 215 | class MutationResult(ObjectType): 216 | ok = Boolean() 217 | 218 | 219 | class MutationBase(Mutation): 220 | Output = MutationResult 221 | TARGET = 'repo' 222 | 223 | @classmethod 224 | def mutate_allowed(cls, permissions, **kwargs): 225 | return False 226 | 227 | @classmethod 228 | async def mutate(cls, root, info, **kwargs): 229 | if not skip_permissions_check(info): 230 | permissions = get_user_permissions(info) 231 | if ADMIN not in permissions: 232 | if not cls.mutate_allowed(permissions, **kwargs): 233 | raise PermissionError() 234 | 235 | try: 236 | method = getattr(info.context[cls.TARGET], cls.REPO_METHOD) 237 | result = method(**kwargs) 238 | if isinstance(result, Future) or iscoroutine(result): 239 | await result 240 | except (Auth0Error, lektorium.repo.ExceptionBase): 241 | return MutationResult(ok=False) 242 | 243 | return MutationResult(ok=True) 244 | 245 | 246 | class ChangePermissionsMixin: 247 | TARGET = 'auth0_client' 248 | 249 | class Arguments: 250 | user_id = String() 251 | permissions = List(String) 252 | 253 | 254 | class AddPermissions(ChangePermissionsMixin, MutationBase): 255 | REPO_METHOD = 'set_user_permissions' 256 | 257 | 258 | class DeletePermissions(ChangePermissionsMixin, MutationBase): 259 | REPO_METHOD = 'delete_user_permissions' 260 | 261 | 262 | class SitePermissionMixin: 263 | @classmethod 264 | def mutate_allowed(cls, permissions, site_id, **kwargs): 265 | return f'user:{site_id}' in permissions 266 | 267 | 268 | class DestroySession(SitePermissionMixin, MutationBase): 269 | REPO_METHOD = 'destroy_session' 270 | 271 | class Arguments: 272 | session_id = String() 273 | 274 | 275 | class ParkSession(SitePermissionMixin, MutationBase): 276 | REPO_METHOD = 'park_session' 277 | 278 | class Arguments: 279 | session_id = String() 280 | 281 | 282 | class RequestRelease(SitePermissionMixin, MutationBase): 283 | REPO_METHOD = 'request_release' 284 | 285 | class Arguments: 286 | session_id = String() 287 | 288 | 289 | class UnparkSession(SitePermissionMixin, MutationBase): 290 | REPO_METHOD = 'unpark_session' 291 | 292 | class Arguments: 293 | session_id = String() 294 | 295 | 296 | class CreateSession(SitePermissionMixin, MutationBase): 297 | REPO_METHOD = 'create_session' 298 | 299 | class Arguments: 300 | site_id = String() 301 | themes = List(String, required=False) 302 | 303 | @classmethod 304 | async def mutate(cls, root, info, **kwargs): 305 | jwt_user = info.context.get('userdata') 306 | return super().mutate(root, info, custodian=jwt_user, **kwargs) 307 | 308 | 309 | class CreateSite(MutationBase): 310 | REPO_METHOD = 'create_site' 311 | 312 | class Arguments: 313 | site_id = String() 314 | name = String(name='siteName') 315 | owner = String() 316 | themes = List(String, required=False) 317 | 318 | @classmethod 319 | async def mutate(cls, root, info, **kwargs): 320 | owner = kwargs.pop('owner', None) 321 | if not owner: 322 | owner = info.context.get('userdata') 323 | else: 324 | email, _, name = owner.partition(',') 325 | owner = (name, email) 326 | return super().mutate(root, info, owner=owner, **kwargs) 327 | 328 | 329 | MutationQuery = type( 330 | 'MutationQuery', 331 | (ObjectType,), 332 | {cls.REPO_METHOD: getattr(cls, 'Field')() for cls in MutationBase.__subclasses__()}, 333 | ) 334 | -------------------------------------------------------------------------------- /src/lektorium/repo/local/server.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio 3 | import functools 4 | import logging 5 | import os 6 | import pathlib 7 | import random 8 | import subprocess 9 | from datetime import datetime 10 | from types import MappingProxyType 11 | 12 | import aiodocker 13 | from more_itertools import one 14 | from spherical.dev.utils import flatten_options 15 | 16 | 17 | EMPTY_DICT = MappingProxyType({}) 18 | 19 | 20 | class Server(metaclass=abc.ABCMeta): 21 | START_PORT = 5000 22 | END_PORT = 6000 23 | 24 | @classmethod 25 | def generate_port(cls, busy): 26 | port = None 27 | if not set(range(cls.START_PORT, cls.END_PORT + 1)).difference(busy): 28 | raise RuntimeError('No free ports available') 29 | while not port or port in busy: 30 | port = random.randint(cls.START_PORT, cls.END_PORT) 31 | return port 32 | 33 | @property 34 | def sessions(self): 35 | raise RuntimeError('sessions tracking not implemented') 36 | 37 | @abc.abstractmethod 38 | def serve_lektor(self, path, session=EMPTY_DICT): 39 | pass 40 | 41 | @abc.abstractmethod 42 | def serve_static(self, path): 43 | pass 44 | 45 | @abc.abstractmethod 46 | def stop_server(self, path, finalizer=None): 47 | pass 48 | 49 | def __repr__(self): 50 | return f'{self.__class__.__name__}()' 51 | 52 | 53 | class FakeServer(Server): 54 | def __init__(self): 55 | self.serves = {} 56 | 57 | def serve_lektor(self, path, session=EMPTY_DICT): 58 | if path in self.serves: 59 | raise RuntimeError() 60 | port = self.generate_port(list(self.serves.values())) 61 | self.serves[path] = port 62 | return f'http://localhost:{self.serves[path]}/' 63 | 64 | def stop_server(self, path, finalizer=None): 65 | self.serves.pop(path) 66 | callable(finalizer) and finalizer() 67 | 68 | serve_static = serve_lektor 69 | 70 | 71 | class AsyncServer(Server): 72 | LOGGER = logging.getLogger() 73 | 74 | def __init__(self): 75 | self.serves = {} 76 | 77 | def serve_lektor(self, path, session=EMPTY_DICT): 78 | def resolver(started): 79 | if started.done(): 80 | if started.exception() is not None: 81 | try: 82 | started.result() 83 | except Exception: 84 | self.LOGGER.exception('error') 85 | return ('Failed to start',) * 2 86 | return (started.result(),) * 2 87 | return (functools.partial(resolver, started), 'Starting') 88 | 89 | started = asyncio.Future() 90 | task = asyncio.ensure_future(self.start(path, started, dict(session))) 91 | self.serves[path] = [lambda: task if task.cancel() else task, started] 92 | return functools.partial(resolver, started) 93 | 94 | serve_static = serve_lektor 95 | 96 | def stop_server(self, path, finalizer=None): 97 | result = asyncio.ensure_future(self.stop(path, finalizer)) 98 | result.add_done_callback(lambda _: result.result()) 99 | 100 | @abc.abstractmethod 101 | async def start(self, path, started, session): 102 | pass 103 | 104 | async def stop(self, path, finalizer=None): 105 | task_cancel, _ = self.serves[path] 106 | finalize = asyncio.gather(task_cancel(), return_exceptions=True) 107 | finalize.add_done_callback( 108 | lambda _: callable(finalizer) and finalizer(), 109 | ) 110 | await finalize 111 | 112 | 113 | class AsyncLocalServer(AsyncServer): 114 | COMMAND = 'lektor server -h 0.0.0.0 -p {port}' 115 | 116 | async def start(self, path, started, session): 117 | log = logging.getLogger(f'Server({path})') 118 | log.info('starting') 119 | try: 120 | try: 121 | port = self.generate_port(()) 122 | proc = await asyncio.create_subprocess_shell( 123 | self.COMMAND.format(port=port), 124 | cwd=path, 125 | stdout=subprocess.PIPE, 126 | stderr=subprocess.STDOUT, 127 | ) 128 | async for line in proc.stdout: 129 | if line.strip().startswith(b'Finished prune'): 130 | break 131 | else: 132 | await proc.communicate() 133 | raise RuntimeError('early process end') 134 | except Exception as exc: 135 | log.error('failed') 136 | started.set_exception(exc) 137 | return 138 | log.info('started') 139 | started.set_result(f'http://localhost:{port}/') 140 | try: 141 | async for line in proc.stdout: 142 | pass 143 | finally: 144 | proc.terminate() 145 | await proc.communicate() 146 | finally: 147 | log.info('finished') 148 | 149 | 150 | class AsyncDockerServer(AsyncServer): 151 | LEKTOR_PORT = 5000 152 | LABEL_PREFIX = 'lektorium' 153 | 154 | def __init__( 155 | self, 156 | *, 157 | auto_remove=True, 158 | lektor_image='lektorium-lektor', 159 | network=None, 160 | server_container='lektorium', 161 | ): 162 | super().__init__() 163 | if not pathlib.Path('/var/run/docker.sock').exists(): 164 | raise RuntimeError('/var/run/docker.sock not exists') 165 | self.auto_remove = auto_remove 166 | self.lektor_image = lektor_image 167 | self.network = network 168 | self.sessions_domain = os.environ.get('LEKTORIUM_SESSIONS_DOMAIN', None) 169 | self.server_container = server_container 170 | 171 | @property 172 | async def sessions(self): 173 | def parse(containers): 174 | for x in containers: 175 | if f'{self.LABEL_PREFIX}.edit_url' not in x['Config']['Labels']: 176 | continue 177 | session = { 178 | k[len(self.LABEL_PREFIX) + 1 :]: v 179 | for k, v in x['Config']['Labels'].items() 180 | if k.startswith(self.LABEL_PREFIX) 181 | } 182 | if 'creation_time' in session: 183 | creation_time = float(session['creation_time']) 184 | creation_time = datetime.fromtimestamp(creation_time) 185 | session['creation_time'] = creation_time 186 | yield session 187 | 188 | docker = aiodocker.Docker() 189 | containers = (await c.show() for c in await docker.containers.list()) 190 | return list(parse([x async for x in containers])) 191 | 192 | @property 193 | async def network_mode(self): 194 | if self.network is None: 195 | docker = aiodocker.Docker() 196 | containers = (await c.show() for c in await docker.containers.list()) 197 | networks = [ 198 | c['HostConfig']['NetworkMode'] async for c in containers if c['Name'] == f'/{self.server_container}' 199 | ] 200 | self.network = one(networks) 201 | return self.network 202 | 203 | def env_vars(self, session): 204 | return [] 205 | 206 | async def start(self, path, started, session): 207 | log = logging.getLogger(f'Server({path})') 208 | log.info('starting') 209 | container, stream = None, None 210 | try: 211 | try: 212 | session_id = pathlib.Path(path).name 213 | container_name = f'{self.lektor_image}-{session_id}' 214 | labels = flatten_options(self.lektor_labels(session_id), 'traefik') 215 | session = self.update_session_params(session_id, container_name, session) 216 | labels.update(flatten_options(session, self.LABEL_PREFIX)) 217 | docker = aiodocker.Docker() 218 | container = await docker.containers.run( 219 | name=container_name, 220 | config=dict( 221 | HostConfig=dict( 222 | AutoRemove=self.auto_remove, 223 | NetworkMode=await self.network_mode, 224 | VolumesFrom=[ 225 | self.server_container, 226 | ], 227 | ), 228 | Cmd=['--project', f'{path}', 'server', '--host', '0.0.0.0'], 229 | Env=self.env_vars(session), 230 | Labels=labels, 231 | Image=self.lektor_image, 232 | ), 233 | ) 234 | stream = container.log(stdout=True, stderr=True, follow=True) 235 | async for line in stream: 236 | logging.debug(line.strip()) 237 | if line.strip().startswith('Finished prune'): 238 | break 239 | else: 240 | raise RuntimeError('early process end') 241 | except Exception as exc: 242 | log.error('failed') 243 | started.set_exception(exc) 244 | if container is not None: 245 | await asyncio.gather( 246 | container.kill(), 247 | return_exceptions=True, 248 | ) 249 | else: 250 | log.info('started') 251 | started.set_result((session['edit_url'], session['preview_url'], session['legacy_admin_url'])) 252 | self.serves[path][0] = container.kill 253 | finally: 254 | if stream is not None: 255 | await asyncio.gather( 256 | stream.aclose(), 257 | return_exceptions=True, 258 | ) 259 | finally: 260 | log.info('start ended') 261 | 262 | async def stop(self, path, finalizer=None): 263 | if path in self.serves: 264 | return await super().stop(path, finalizer) 265 | session_id = path.name 266 | container_name = f'{self.lektor_image}-{session_id}' 267 | for container in await aiodocker.Docker().containers.list(): 268 | info = await container.show() 269 | if info['Name'] == f'/{container_name}': 270 | await container.kill() 271 | callable(finalizer) and finalizer() 272 | 273 | def update_session_params(self, session_id, container_name, session): 274 | session = { 275 | **session, 276 | 'edit_url': self.session_address(session_id, container_name), 277 | 'preview_url': '', 278 | 'legacy_admin_url': '', 279 | } 280 | if 'creation_time' in session: 281 | session['creation_time'] = str(session['creation_time'].timestamp()) 282 | session.pop('parked_time', None) 283 | return session 284 | 285 | def session_address(self, session_id, container_name): 286 | if self.sessions_domain is None: 287 | return f'https://{container_name}:{self.LEKTOR_PORT}/' 288 | return f'http://{session_id}.{self.sessions_domain}' 289 | 290 | def lektor_labels(self, session_id): 291 | if self.sessions_domain is None: 292 | return {} 293 | route_name = f'{self.lektor_image}-{session_id}' 294 | skip_resolver = os.environ.get('LEKTORIUM_SKIP_RESOLVER', False) 295 | return { 296 | 'enable': 'true', 297 | 'http.routers': { 298 | route_name: { 299 | 'entrypoints': 'websecure', 300 | 'middlewares': 'no-cache-headers', 301 | 'rule': f'Host(`{session_id}.{self.sessions_domain}`)', 302 | 'tls': {}, 303 | 'tls.domains[0].main': f'*.{self.sessions_domain}', 304 | **({} if skip_resolver else {'tls.certresolver': 'le'}), 305 | }, 306 | }, 307 | 'http.middlewares.no-cache-headers.headers.customresponseheaders': { 308 | 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 309 | 'Pragma': 'no-cache', 310 | 'Expires': '0', 311 | }, 312 | f'http.services.{route_name}.loadbalancer.server.port': f'{self.LEKTOR_PORT}', 313 | } 314 | 315 | 316 | class AsyncDockerServerLectern(AsyncDockerServer): 317 | def __init__(self, *, lektor_image='lektorium-lectern', **kwargs): 318 | super().__init__(lektor_image=lektor_image, **kwargs) 319 | 320 | def env_vars(self, session): 321 | return [ 322 | f'LECTERN_TINYMCE_KEY={os.environ.get("TINYMCE_KEY", "")}', 323 | f'LECTERN_REDIRECT_URL={session["preview_url"]}', 324 | ] 325 | 326 | def update_session_params(self, session_id, container_name, session): 327 | session = super().update_session_params(session_id, container_name, session) 328 | session['preview_url'] = self.preview_session_address(session_id, container_name) 329 | session['legacy_admin_url'] = self.legacy_admin_session_address(session_id, container_name) 330 | return session 331 | 332 | def session_address(self, session_id, container_name): 333 | if self.sessions_domain is None: 334 | return f'https://{container_name}:{self.LEKTOR_PORT}/lectern-admin/ui/' 335 | return f'http://{session_id}.{self.sessions_domain}/lectern-admin/ui/' 336 | 337 | def preview_session_address(self, session_id, container_name): 338 | if self.sessions_domain is None: 339 | return f'https://{container_name}:{self.LEKTOR_PORT}/' 340 | return f'http://{session_id}-preview.{self.sessions_domain}' 341 | 342 | def legacy_admin_session_address(self, session_id, container_name): 343 | if self.sessions_domain is None: 344 | return f'https://{container_name}:{self.LEKTOR_PORT}/admin/' 345 | return f'http://{session_id}-legacy-admin.{self.sessions_domain}/admin/' 346 | 347 | def lektor_labels(self, session_id): 348 | if self.sessions_domain is None: 349 | return {} 350 | labels = super().lektor_labels(session_id) 351 | route_name = one(labels['http.routers'].keys()) 352 | labels[f'http.services.{route_name}.loadbalancer.server.port'] = f'{self.LEKTOR_PORT}' 353 | labels['http.routers'][f'{route_name}']['service'] = f'{route_name}' 354 | 355 | labels[f'http.services.{route_name}-preview.loadbalancer.server.port'] = f'{self.LEKTOR_PORT}' 356 | labels['http.routers'][f'{route_name}-preview'] = {**labels['http.routers'][route_name]} 357 | labels['http.routers'][f'{route_name}-preview']['rule'] = f'Host(`{session_id}-preview.{self.sessions_domain}`)' 358 | labels['http.routers'][f'{route_name}-preview']['service'] = f'{route_name}-preview' 359 | 360 | labels[f'http.services.{route_name}-legacy-admin.loadbalancer.server.port'] = f'{self.LEKTOR_PORT}' 361 | labels['http.routers'][f'{route_name}-legacy-admin'] = {**labels['http.routers'][route_name]} 362 | labels['http.routers'][f'{route_name}-legacy-admin']['rule'] = ( 363 | f'Host(`{session_id}-legacy-admin.{self.sessions_domain}`)' 364 | ) 365 | labels['http.routers'][f'{route_name}-legacy-admin']['service'] = f'{route_name}-legacy-admin' 366 | return labels 367 | -------------------------------------------------------------------------------- /tests/test_graphql.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import copy 3 | 4 | import graphene.test 5 | import pytest 6 | from graphql.execution.executors.asyncio import AsyncioExecutor 7 | 8 | import lektorium.repo 9 | import lektorium.schema 10 | from lektorium.auth0 import FakeAuth0Client 11 | 12 | 13 | def deorder(obj): 14 | '''Removes OrderedDict's in object tree''' 15 | if isinstance(obj, (collections.OrderedDict, dict)): 16 | return {k: deorder(v) for k, v in obj.items()} 17 | elif isinstance(obj, list): 18 | return [deorder(k) for k in obj] 19 | return obj 20 | 21 | 22 | @pytest.fixture 23 | def client(): 24 | return graphene.test.Client( 25 | graphene.Schema( 26 | query=lektorium.schema.Query, 27 | mutation=lektorium.schema.MutationQuery, 28 | ), 29 | context={ 30 | 'repo': lektorium.repo.ListRepo( 31 | copy.deepcopy(lektorium.repo.SITES), 32 | ), 33 | 'user_permissions': ['fake:permission'], 34 | 'auth0_client': FakeAuth0Client(), 35 | 'skip_permissions_check': True, 36 | }, 37 | executor=AsyncioExecutor(), 38 | ) 39 | 40 | 41 | def test_query_sites(client): 42 | result = client.execute(r'''{ 43 | sites { 44 | siteId 45 | } 46 | }''') 47 | assert deorder(result) == { 48 | 'data': { 49 | 'sites': [ 50 | {'siteId': 'bow'}, 51 | {'siteId': 'uci'}, 52 | {'siteId': 'ldi'}, 53 | ], 54 | }, 55 | } 56 | 57 | 58 | def test_query_edit_session(client): 59 | result = client.execute(r'''{ 60 | sessions { 61 | sessionId 62 | } 63 | }''') 64 | assert deorder(result) == { 65 | 'data': { 66 | 'sessions': [ 67 | {'sessionId': 'widgets-1'}, 68 | ], 69 | }, 70 | } 71 | 72 | 73 | def test_session_edit_url(client): 74 | result = client.execute(r'''{ 75 | sessions { 76 | editUrl 77 | } 78 | }''') 79 | assert deorder(result) == { 80 | 'data': { 81 | 'sessions': [ 82 | {'editUrl': 'https://cmsdciks.cms.acme.com'}, 83 | ], 84 | }, 85 | } 86 | 87 | 88 | def test_query_parked_session(client): 89 | result = client.execute(r'''{ 90 | sessions(parked: true) { 91 | sessionId 92 | } 93 | }''') 94 | assert deorder(result) == { 95 | 'data': { 96 | 'sessions': [ 97 | {'sessionId': 'pantssss'}, 98 | {'sessionId': 'pantss1'}, 99 | ], 100 | }, 101 | } 102 | 103 | 104 | def test_create_session(client): 105 | result = client.execute(r'''mutation { 106 | createSession(siteId: "uci") { 107 | ok 108 | } 109 | }''') 110 | assert deorder(result) == { 111 | 'data': { 112 | 'createSession': { 113 | 'ok': True, 114 | }, 115 | }, 116 | } 117 | result = client.execute(r'''{ 118 | sessions { 119 | siteName 120 | } 121 | }''') 122 | assert deorder(result) == { 123 | 'data': { 124 | 'sessions': [ 125 | {'siteName': 'Buy Our Widgets'}, 126 | {'siteName': 'Underpants Collectors International'}, 127 | ], 128 | }, 129 | } 130 | 131 | 132 | def test_create_session_other_exist(client): 133 | result = client.execute(r'''mutation { 134 | createSession(siteId: "bow") { 135 | ok 136 | } 137 | }''') 138 | assert deorder(result) == { 139 | 'data': { 140 | 'createSession': { 141 | 'ok': False, 142 | }, 143 | }, 144 | }, 'Server should fail to create session if another already exists' 145 | 146 | 147 | def test_park_session(client): 148 | result = client.execute(r'''mutation { 149 | parkSession(sessionId: "widgets-1") { 150 | ok 151 | } 152 | }''') 153 | assert deorder(result) == { 154 | 'data': { 155 | 'parkSession': { 156 | 'ok': True, 157 | }, 158 | }, 159 | } 160 | result = client.execute(r'''{ 161 | sessions(parked: true) { 162 | sessionId 163 | } 164 | }''') 165 | assert deorder(result) == { 166 | 'data': { 167 | 'sessions': [ 168 | {'sessionId': 'widgets-1'}, 169 | {'sessionId': 'pantssss'}, 170 | {'sessionId': 'pantss1'}, 171 | ], 172 | }, 173 | } 174 | 175 | 176 | def test_park_unknown_session(client): 177 | result = client.execute(r'''mutation { 178 | parkSession(sessionId: "test12345") { 179 | ok 180 | } 181 | }''') 182 | assert deorder(result) == { 183 | 'data': { 184 | 'parkSession': { 185 | 'ok': False, 186 | }, 187 | }, 188 | }, 'Server should fail to park unknown session' 189 | 190 | 191 | def test_park_parked_session(client): 192 | client.execute(r'''mutation { 193 | parkSession(sessionId: "widgets-1") { 194 | ok 195 | } 196 | }''') 197 | result = client.execute(r'''mutation { 198 | parkSession(sessionId: "widgets-1") { 199 | ok 200 | } 201 | }''') 202 | assert deorder(result) == { 203 | 'data': { 204 | 'parkSession': { 205 | 'ok': False, 206 | }, 207 | }, 208 | }, 'Server should fail to park parked session' 209 | 210 | 211 | def test_unpark_session(client): 212 | result = client.execute(r'''mutation { 213 | unparkSession(sessionId: "pantssss") { 214 | ok 215 | } 216 | }''') 217 | assert deorder(result) == { 218 | 'data': { 219 | 'unparkSession': { 220 | 'ok': True, 221 | }, 222 | }, 223 | } 224 | result = client.execute(r'''{ 225 | editSessions: sessions(parked: false) { 226 | sessionId 227 | } 228 | parkedSessions: sessions(parked: true) { 229 | sessionId 230 | } 231 | }''') 232 | assert deorder(result) == { 233 | 'data': { 234 | 'editSessions': [ 235 | {'sessionId': 'widgets-1'}, 236 | {'sessionId': 'pantssss'}, 237 | ], 238 | 'parkedSessions': [ 239 | {'sessionId': 'pantss1'}, 240 | ], 241 | }, 242 | } 243 | 244 | 245 | def test_unpark_session_another_exist(client): 246 | client.execute(r'''mutation { 247 | unparkSession(sessionId: "pantssss") { 248 | ok 249 | } 250 | }''') 251 | result = client.execute(r'''mutation { 252 | unparkSession(sessionId: "pantss1") { 253 | ok 254 | } 255 | }''') 256 | assert deorder(result) == { 257 | 'data': { 258 | 'unparkSession': { 259 | 'ok': False, 260 | }, 261 | }, 262 | }, ( 263 | 'Server should fail to unpark session when there is an active session ' 264 | 'for same website' 265 | ) 266 | 267 | 268 | def test_unpark_unknown_session(client): 269 | result = client.execute(r'''mutation { 270 | unparkSession(sessionId: "test12345") { 271 | ok 272 | } 273 | }''') 274 | assert deorder(result) == { 275 | 'data': { 276 | 'unparkSession': { 277 | 'ok': False, 278 | }, 279 | }, 280 | }, 'Server should fail to unpark unknown session' 281 | 282 | 283 | def test_unpark_unkparked_session(client): 284 | result = client.execute(r'''mutation { 285 | unparkSession(sessionId: "widgets-1") { 286 | ok 287 | } 288 | }''') 289 | assert deorder(result) == { 290 | 'data': { 291 | 'unparkSession': { 292 | 'ok': False, 293 | }, 294 | }, 295 | }, 'Server should fail to unpark session that was not parked' 296 | 297 | 298 | def test_request_release(client): 299 | result = client.execute(r'''mutation { 300 | requestRelease(sessionId: "widgets-1") { 301 | ok 302 | } 303 | }''') 304 | assert deorder(result) == { 305 | 'data': { 306 | 'requestRelease': { 307 | 'ok': True, 308 | }, 309 | }, 310 | } 311 | 312 | 313 | def test_destroy_session(client): 314 | result = client.execute(r'''mutation { 315 | destroySession(sessionId: "pantss1") { 316 | ok 317 | } 318 | }''') 319 | assert deorder(result) == { 320 | 'data': { 321 | 'destroySession': { 322 | 'ok': True, 323 | }, 324 | }, 325 | } 326 | result = client.execute(r'''{ 327 | sessions(parked: true) { 328 | sessionId 329 | } 330 | }''') 331 | assert deorder(result) == { 332 | 'data': { 333 | 'sessions': [ 334 | {'sessionId': 'pantssss'}, 335 | ], 336 | }, 337 | } 338 | 339 | 340 | def test_destroy_unknown_session(client): 341 | result = client.execute(r'''mutation { 342 | destroySession(sessionId: "test12345") { 343 | ok 344 | } 345 | }''') 346 | assert deorder(result) == { 347 | 'data': { 348 | 'destroySession': { 349 | 'ok': False, 350 | }, 351 | }, 352 | }, 'Server should fail to destroy unknown session' 353 | 354 | 355 | def test_resolve_funcs(client): 356 | result = client.execute(r'''{ 357 | sessions { 358 | productionUrl 359 | stagingUrl 360 | parked 361 | } 362 | }''') 363 | assert deorder(result) == { 364 | 'data': { 365 | 'sessions': [{ 366 | 'productionUrl': 'https://bow.acme.com', 367 | 'stagingUrl': 'https://bow-test.acme.com', 368 | 'parked': False, 369 | }], 370 | }, 371 | } 372 | 373 | 374 | def test_broken_parked_resolve(client): 375 | result = client.execute(r'''{ 376 | sites { 377 | sessions { 378 | parked 379 | } 380 | } 381 | }''') 382 | assert deorder(result) == { 383 | 'data': { 384 | 'sites': [ 385 | {'sessions': [{'parked': False}]}, 386 | {'sessions': [{'parked': True}, {'parked': True}]}, 387 | {'sessions': None}, 388 | ], 389 | }, 390 | } 391 | 392 | 393 | def test_create_site(client): 394 | result = client.execute(r'''mutation { 395 | createSite(siteId:"test" siteName:"test") { 396 | ok 397 | } 398 | }''') 399 | assert deorder(result) == { 400 | 'data': { 401 | 'createSite': { 402 | 'ok': True, 403 | }, 404 | }, 405 | } 406 | result = client.execute(r'''{ 407 | sites { 408 | siteId 409 | } 410 | }''') 411 | assert deorder(result) == { 412 | 'data': { 413 | 'sites': [ 414 | {'siteId': 'bow'}, 415 | {'siteId': 'uci'}, 416 | {'siteId': 'ldi'}, 417 | {'siteId': 'test'}, 418 | ], 419 | }, 420 | } 421 | 422 | 423 | def test_parked_resolve(client): 424 | client.execute(r'''mutation { 425 | createSession(siteId: "uci") { 426 | ok 427 | } 428 | }''') 429 | result = client.execute(r'''{ 430 | sites { 431 | sessions { 432 | parked 433 | } 434 | } 435 | }''') 436 | assert deorder(result) == { 437 | 'data': { 438 | 'sites': [ 439 | {'sessions': [{'parked': False}]}, 440 | {'sessions': [ 441 | {'parked': True}, 442 | {'parked': True}, 443 | {'parked': False}, 444 | ]}, 445 | {'sessions': None}, 446 | ], 447 | }, 448 | } 449 | 450 | 451 | def test_get_users(client): 452 | result = client.execute(r''' { 453 | users { 454 | userId 455 | } 456 | }''') 457 | assert deorder(result) == { 458 | 'data': { 459 | 'users': [ 460 | {'userId': 'test_id'}, 461 | ], 462 | }, 463 | } 464 | 465 | 466 | def test_get_user_permissions(client): 467 | result = client.execute(r''' { 468 | userPermissions(userId: "test_id") { 469 | permissionName 470 | } 471 | }''') 472 | assert deorder(result) == { 473 | 'data': { 474 | 'userPermissions': [ 475 | {'permissionName': 'Test Permission1'}, 476 | ], 477 | }, 478 | } 479 | 480 | 481 | def test_get_api_permissions(client): 482 | result = client.execute(r''' { 483 | availablePermissions { 484 | value 485 | } 486 | }''') 487 | assert deorder(result) == { 488 | 'data': { 489 | 'availablePermissions': [ 490 | {'value': 'admin'}, 491 | {'value': 'user:bow'}, 492 | {'value': 'user:uci'}, 493 | {'value': 'user:ldi'}, 494 | ], 495 | }, 496 | } 497 | 498 | 499 | def test_set_permissions(client): 500 | result = client.execute(r'''mutation { 501 | setUserPermissions(userId:"test_id", permissions:["Test Permission2"]) { 502 | ok 503 | } 504 | }''') 505 | assert deorder(result) == { 506 | 'data': { 507 | 'setUserPermissions': { 508 | 'ok': True, 509 | }, 510 | }, 511 | } 512 | result = client.execute(r''' { 513 | userPermissions(userId: "test_id") { 514 | permissionName 515 | } 516 | }''') 517 | assert deorder(result) == { 518 | 'data': { 519 | 'userPermissions': [ 520 | {'permissionName': 'Test Permission1'}, 521 | {'permissionName': 'Test Permission2'}, 522 | ], 523 | }, 524 | } 525 | result = client.execute(r'''mutation { 526 | setUserPermissions(userId:"wrong_id", permissions:["Test Permission2"]) { 527 | ok 528 | } 529 | }''') 530 | assert deorder(result) == { 531 | 'data': { 532 | 'setUserPermissions': { 533 | 'ok': False, 534 | }, 535 | }, 536 | } 537 | 538 | 539 | def test_delete_permissions(client): 540 | client.execute(r'''mutation { 541 | setUserPermissions(userId:"test_id", permissions:["Test Permission2"]) { 542 | ok 543 | } 544 | }''') 545 | 546 | result = client.execute(r'''mutation { 547 | deleteUserPermissions(userId:"test_id", permissions:["Test Permission1"]) { 548 | ok 549 | } 550 | }''') 551 | assert deorder(result) == { 552 | 'data': { 553 | 'deleteUserPermissions': { 554 | 'ok': True, 555 | }, 556 | }, 557 | } 558 | result = client.execute(r''' { 559 | userPermissions(userId: "test_id") { 560 | permissionName 561 | } 562 | }''') 563 | assert deorder(result) == { 564 | 'data': { 565 | 'userPermissions': [ 566 | {'permissionName': 'Test Permission2'}, 567 | ], 568 | }, 569 | } 570 | 571 | result = client.execute(r'''mutation { 572 | deleteUserPermissions(userId:"wrong_id", permissions:["Test Permission1"]) { 573 | ok 574 | } 575 | }''') 576 | assert deorder(result) == { 577 | 'data': { 578 | 'deleteUserPermissions': { 579 | 'ok': False, 580 | }, 581 | }, 582 | } 583 | --------------------------------------------------------------------------------
{{ profile.email }}
19 | {{ JSON.stringify(profile, null, 4) }} 20 |
{{ JSON.stringify(profile, null, 4) }}