diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c33baae..27bbeba 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,6 @@ [0.2.9] - +- starlette is back +- auth middleware - create first chat with member by id = 1 if empty smembers chats_by_author/author_id [0.2.8] @@ -18,4 +19,5 @@ - auth service connection [0.2.5] -- dummy isolation \ No newline at end of file +- dummy isolation +- aiohttp version diff --git a/Dockerfile b/Dockerfile index 6ae1a41..ef27e3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ FROM python:slim WORKDIR /app + +EXPOSE 8080 +ADD nginx.conf.sigil ./ COPY requirements.txt . RUN pip install -r requirements.txt COPY . . -EXPOSE 8080 -CMD ["python", "main.py"] \ No newline at end of file +CMD ["python", "server.py"] diff --git a/main.py b/main.py index eb18334..f9aff34 100644 --- a/main.py +++ b/main.py @@ -1,30 +1,47 @@ -from aiohttp import web -from ariadne import make_executable_schema, load_schema_from_path +import os +from os.path import exists +from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.sessions import SessionMiddleware +from services.auth import JWTAuthenticate from services.redis import redis from resolvers import resolvers +from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, SESSION_SECRET_KEY, MODE -type_defs = load_schema_from_path("inbox.graphql") -schema = make_executable_schema(type_defs, resolvers) +schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers) # type: ignore + +middleware = [ + Middleware(AuthenticationMiddleware, backend=JWTAuthenticate()), + Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY), +] -async def on_startup(_app): - await redis.connect() +async def start_up(): + if MODE == "dev": + if exists(DEV_SERVER_PID_FILE_NAME): + await redis.connect() + return + else: + with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f: + f.write(str(os.getpid())) + else: + await redis.connect() + try: + import sentry_sdk + + sentry_sdk.init(SENTRY_DSN) + except Exception as e: + print("[sentry] init error") + print(e) -async def on_cleanup(_app): +async def shutdown(): await redis.disconnect() -# Run the aiohttp server -if __name__ == "__main__": - app = web.Application() - app.on_startup.append(on_startup) - app.on_cleanup.append(on_cleanup) - app.router.add_route( - "*", - "/graphql", - GraphQL(schema), - ) - web.run_app(app) +app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown]) +app.mount("/", GraphQL(schema, debug=True)) diff --git a/nginx.conf.sigil b/nginx.conf.sigil new file mode 100644 index 0000000..8de054e --- /dev/null +++ b/nginx.conf.sigil @@ -0,0 +1,226 @@ +{{ range $port_map := .PROXY_PORT_MAP | split " " }} +{{ $port_map_list := $port_map | split ":" }} +{{ $scheme := index $port_map_list 0 }} +{{ $listen_port := index $port_map_list 1 }} +{{ $upstream_port := index $port_map_list 2 }} + +map $http_origin $allow_origin { + ~^https?:\/\/((.*\.)?localhost(:\d+)?|discoursio-webapp(-(.*))?\.vercel\.app|(.*\.)?discours\.io)$ $http_origin; + default ""; +} + +{{ if eq $scheme "http" }} +server { + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }}; + {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} + access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; + error_log {{ $.NGINX_ERROR_LOG_PATH }}; +{{ if (and (eq $listen_port "80") ($.SSL_INUSE)) }} + include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; + location / { + return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri; + } +{{ else }} + location / { + + gzip on; + gzip_min_length 1100; + gzip_buffers 4 32k; + gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; + gzip_vary on; + gzip_comp_level 6; + + proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; + proxy_http_version 1.1; + proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }}; + proxy_buffer_size {{ $.PROXY_BUFFER_SIZE }}; + proxy_buffering {{ $.PROXY_BUFFERING }}; + proxy_buffers {{ $.PROXY_BUFFERS }}; + proxy_busy_buffers_size {{ $.PROXY_BUSY_BUFFERS_SIZE }}; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For {{ $.PROXY_X_FORWARDED_FOR }}; + proxy_set_header X-Forwarded-Port {{ $.PROXY_X_FORWARDED_PORT }}; + proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; + proxy_set_header X-Request-Start $msec; + {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} + } + + {{ if $.CLIENT_MAX_BODY_SIZE }}client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }};{{ end }} + include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; + + error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html; + location /400-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } + + error_page 404 /404-error.html; + location /404-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } + + error_page 500 501 502 503 504 505 506 507 508 509 510 511 /500-error.html; + location /500-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } +{{ end }} +} +{{ else if eq $scheme "https"}} +server { + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }}; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }}; + {{ if $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }} + {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} + access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; + error_log {{ $.NGINX_ERROR_LOG_PATH }}; + + ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; + ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; + ssl_protocols TLSv1.2 {{ if eq $.TLS13_SUPPORTED "true" }}TLSv1.3{{ end }}; + ssl_prefer_server_ciphers off; + + keepalive_timeout 70; + {{ if and (eq $.SPDY_SUPPORTED "true") (ne $.HTTP2_SUPPORTED "true") }}add_header Alternate-Protocol {{ $.PROXY_SSL_PORT }}:npn-spdy/2;{{ end }} + + location / { + + gzip on; + gzip_min_length 1100; + gzip_buffers 4 32k; + gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; + gzip_vary on; + gzip_comp_level 6; + + proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; + {{ if eq $.HTTP2_PUSH_SUPPORTED "true" }}http2_push_preload on; {{ end }} + proxy_http_version 1.1; + proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }}; + proxy_buffer_size {{ $.PROXY_BUFFER_SIZE }}; + proxy_buffering {{ $.PROXY_BUFFERING }}; + proxy_buffers {{ $.PROXY_BUFFERS }}; + proxy_busy_buffers_size {{ $.PROXY_BUSY_BUFFERS_SIZE }}; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For {{ $.PROXY_X_FORWARDED_FOR }}; + proxy_set_header X-Forwarded-Port {{ $.PROXY_X_FORWARDED_PORT }}; + proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; + proxy_set_header X-Request-Start $msec; + {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '$allow_origin' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + # + # Custom headers and headers various browsers *should* be OK with but aren't + # + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; + add_header 'Access-Control-Allow-Credentials' 'true'; + # + # Tell client that this pre-flight info is valid for 20 days + # + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + if ($request_method = 'POST') { + add_header 'Access-Control-Allow-Origin' '$allow_origin' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + } + + if ($request_method = 'GET') { + add_header 'Access-Control-Allow-Origin' '$allow_origin' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + } + } + + {{ if $.CLIENT_MAX_BODY_SIZE }}client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }};{{ end }} + include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; + + error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html; + location /400-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } + + error_page 404 /404-error.html; + location /404-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } + + error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html; + location /500-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } + + error_page 502 /502-error.html; + location /502-error.html { + root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; + internal; + } +} +{{ else if eq $scheme "grpc"}} +{{ if eq $.GRPC_SUPPORTED "true"}}{{ if eq $.HTTP2_SUPPORTED "true"}} +server { + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} http2; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} http2; + {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} + access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; + error_log {{ $.NGINX_ERROR_LOG_PATH }}; + location / { + grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; + } + + {{ if $.CLIENT_MAX_BODY_SIZE }}client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }};{{ end }} + include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; +} +{{ end }}{{ end }} +{{ else if eq $scheme "grpcs"}} +{{ if eq $.GRPC_SUPPORTED "true"}}{{ if eq $.HTTP2_SUPPORTED "true"}} +server { + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl http2; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl http2; + {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} + access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; + error_log {{ $.NGINX_ERROR_LOG_PATH }}; + + ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; + ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; + ssl_protocols TLSv1.2 {{ if eq $.TLS13_SUPPORTED "true" }}TLSv1.3{{ end }}; + ssl_prefer_server_ciphers off; + + location / { + grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; + } + + {{ if $.CLIENT_MAX_BODY_SIZE }}client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }};{{ end }} + include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; +} +{{ end }}{{ end }} +{{ end }} +{{ end }} + +{{ if $.DOKKU_APP_WEB_LISTENERS }} +{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }} +upstream {{ $.APP }}-{{ $upstream_port }} { +{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }} +{{ $listener_list := $listeners | split ":" }} +{{ $listener_ip := index $listener_list 0 }} + server {{ $listener_ip }}:{{ $upstream_port }};{{ end }} +} +{{ end }}{{ end }} diff --git a/requirements.txt b/requirements.txt index 1bde1ec..11896d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ -aiohttp +starlette aioredis ariadne sqlalchemy gql +pydantic +httpx +uvicorn diff --git a/server.py b/server.py new file mode 100644 index 0000000..4e9f774 --- /dev/null +++ b/server.py @@ -0,0 +1,59 @@ +import sys +import uvicorn +from settings import PORT + + +def exception_handler(exception_type, exception, traceback, debug_hook=sys.excepthook): + print("%s: %s" % (exception_type.__name__, exception)) + + +log_settings = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(message)s", + "use_colors": None, + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "uvicorn": {"handlers": ["default"], "level": "INFO"}, + "uvicorn.error": {"level": "INFO", "handlers": ["default"], "propagate": True}, + "uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False}, + }, +} + +local_headers = [ + ("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"), + ("Access-Control-Allow-Origin", "https://localhost:3000"), + ( + "Access-Control-Allow-Headers", + "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization", + ), + ("Access-Control-Expose-Headers", "Content-Length,Content-Range"), + ("Access-Control-Allow-Credentials", "true"), +] + + +if __name__ == "__main__": + sys.excepthook = exception_handler + uvicorn.run( + "main:app", host="0.0.0.0", port=PORT, proxy_headers=True, server_header=True + ) diff --git a/services/auth.py b/services/auth.py index 057c3f1..133138d 100644 --- a/services/auth.py +++ b/services/auth.py @@ -1,19 +1,38 @@ +from typing import Optional +from pydantic import BaseModel from functools import wraps -from gql.transport import aiohttp -import aiohttp -import json +from starlette.authentication import AuthenticationBackend +from starlette.requests import HTTPConnection +from graphql.error import GraphQLError +from httpx import AsyncClient from services.db import local_session from settings import AUTH_URL from orm.author import Author -from graphql.error import GraphQLError -class BaseHttpException(GraphQLError): - code = 500 - message = "500 Server error" +class AuthUser(BaseModel): + user_id: Optional[int] + username: Optional[str] -class Unauthorized(BaseHttpException): +class AuthCredentials(BaseModel): + user_id: Optional[int] = None + scopes: Optional[dict] = {} + logged_in: bool = False + error_message: str = "" + + +class JWTAuthenticate(AuthenticationBackend): + async def authenticate(self, request: HTTPConnection): + scopes = {} # TODO: integrate await user.get_permission + logged_in, user_id = await check_auth(request) + return ( + AuthCredentials(user_id=user_id, scopes=scopes, logged_in=logged_in), + AuthUser(user_id=user_id, username=""), + ) + + +class Unauthorized(GraphQLError): code = 401 message = "401 Unauthorized" @@ -26,16 +45,14 @@ async def check_auth(req): else {"query": "{ session { user { id } } }"} ) headers = {"Authorization": token, "Content-Type": "application/json"} - async with aiohttp.ClientSession(headers=headers) as session: - async with session.post(AUTH_URL, data=json.dumps(gql)) as response: - if response.status != 200: - return False, None - r = await response.json() - user_id = ( - r.get("data", {}).get("session", {}).get("user", {}).get("id", None) - ) - is_authenticated = user_id is not None - return is_authenticated, user_id + async with AsyncClient() as client: + response = await client.post(AUTH_URL, headers=headers, data=gql) + if response.status_code != 200: + return False, None + r = response.json() + user_id = r.get("data", {}).get("session", {}).get("user", {}).get("id", None) + is_authenticated = user_id is not None + return is_authenticated, user_id def author_id_by_user_id(user_id): diff --git a/settings.py b/settings.py index 23550d3..6899b5d 100644 --- a/settings.py +++ b/settings.py @@ -9,3 +9,8 @@ DB_URL = ( REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" API_BASE = environ.get("API_BASE") or "" AUTH_URL = environ.get("AUTH_URL") or "" +MODE = environ.get("MODE") or "production" +SENTRY_DSN = environ.get("SENTRY_DSN") +SESSION_SECRET_KEY = environ.get("SESSION_SECRET_KEY") or "!secret" +DEV_SERVER_PID_FILE_NAME = "dev-server.pid" +SESSION_TOKEN_HEADER = "Authorization"