From 3136eecd7e4e73d5abf0e3f81c94c5a4cbeeb3ca Mon Sep 17 00:00:00 2001 From: tonyrewin Date: Sat, 17 Sep 2022 21:12:14 +0300 Subject: [PATCH] migration, auth, refactoring, formatting --- .editorconfig | 3 +- .flake8 | 2 +- auth/__init__.py | 3 - auth/authenticate.py | 86 ++--------- auth/authorize.py | 45 ------ auth/credentials.py | 6 +- auth/email.py | 112 ++++---------- auth/identity.py | 63 +++++--- auth/jwtcodec.py | 13 +- auth/oauth.py | 180 +++++++++++------------ auth/password.py | 11 -- auth/templates/auth_email.tmpl | 1 - auth/templates/confirm_email.tmpl | 1 - auth/templates/reset_password_email.tmpl | 1 - auth/tokenstorage.py | 50 +++++++ auth/validations.py | 27 ---- base/exceptions.py | 2 +- base/orm.py | 2 + base/redis.py | 69 ++++----- main.py | 13 +- migration/__init__.py | 77 +++++----- migration/bson2json.py | 5 +- migration/export.py | 40 ++++- migration/extract.py | 3 +- migration/html2text/__init__.py | 10 +- migration/html2text/cli.py | 1 + migration/html2text/utils.py | 9 +- migration/tables/comments.py | 23 ++- migration/tables/content_items.py | 51 ++++--- migration/tables/replacements.json | 36 ++++- migration/tables/topics.py | 2 +- migration/tables/users.py | 36 +---- orm/__init__.py | 70 ++++----- orm/collab.py | 2 + orm/collection.py | 2 + orm/community.py | 2 + orm/notification.py | 1 + orm/rbac.py | 2 + orm/reaction.py | 4 +- orm/shout.py | 8 +- orm/topic.py | 2 + orm/user.py | 2 + requirements.txt | 33 +++-- resolvers/__init__.py | 78 +++++----- resolvers/auth.py | 148 +++++++++---------- resolvers/collab.py | 12 +- resolvers/collection.py | 15 +- resolvers/community.py | 16 +- resolvers/editor.py | 35 +++-- resolvers/feed.py | 13 +- resolvers/inbox.py | 7 +- resolvers/profile.py | 37 +++-- resolvers/reactions.py | 10 +- resolvers/topics.py | 16 +- resolvers/zine.py | 37 +++-- schema.graphql | 6 +- server.py | 59 ++++---- services/auth/roles.py | 2 + services/auth/users.py | 2 + services/main.py | 17 +++ services/stat/reacted.py | 58 ++++---- services/stat/topicstat.py | 20 +-- services/stat/viewed.py | 2 + services/zine/gittask.py | 3 +- services/zine/shoutauthor.py | 1 + services/zine/shoutscache.py | 168 +++++++++++++-------- settings.py | 9 +- validations/auth.py | 16 ++ 68 files changed, 968 insertions(+), 930 deletions(-) delete mode 100644 auth/__init__.py delete mode 100644 auth/authorize.py delete mode 100644 auth/password.py delete mode 100644 auth/templates/auth_email.tmpl delete mode 100644 auth/templates/confirm_email.tmpl delete mode 100644 auth/templates/reset_password_email.tmpl create mode 100644 auth/tokenstorage.py delete mode 100644 auth/validations.py create mode 100644 services/main.py create mode 100644 validations/auth.py diff --git a/.editorconfig b/.editorconfig index 58b88ea4..deb2bac1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,7 @@ root = true [*] -indent_style = tabs -indent_size = 2 +indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace=true diff --git a/.flake8 b/.flake8 index 5af21bf8..e82de95a 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -ignore = E203,W504,W191 +ignore = E203,W504,W191,W503 exclude = .git,__pycache__,orm/rbac.py max-complexity = 10 max-line-length = 108 diff --git a/auth/__init__.py b/auth/__init__.py deleted file mode 100644 index 7d4fb480..00000000 --- a/auth/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from auth.email import load_email_templates - -load_email_templates() diff --git a/auth/authenticate.py b/auth/authenticate.py index 1941b052..68446540 100644 --- a/auth/authenticate.py +++ b/auth/authenticate.py @@ -1,21 +1,20 @@ from functools import wraps from typing import Optional, Tuple -from datetime import datetime, timedelta -from graphql import GraphQLResolveInfo + +from graphql.type import GraphQLResolveInfo from jwt import DecodeError, ExpiredSignatureError from starlette.authentication import AuthenticationBackend from starlette.requests import HTTPConnection + from auth.credentials import AuthCredentials, AuthUser from auth.jwtcodec import JWTCodec -from auth.authorize import Authorize, TokenStorage +from auth.tokenstorage import TokenStorage from base.exceptions import InvalidToken -from orm.user import User from services.auth.users import UserStorage -from base.orm import local_session -from settings import JWT_AUTH_HEADER, EMAIL_TOKEN_LIFE_SPAN +from settings import SESSION_TOKEN_HEADER -class _Authenticate: +class SessionToken: @classmethod async def verify(cls, token: str): """ @@ -32,33 +31,30 @@ class _Authenticate: payload = JWTCodec.decode(token) except ExpiredSignatureError: payload = JWTCodec.decode(token, verify_exp=False) - if not await cls.exists(payload.user_id, token): - raise InvalidToken("Login expired, please login again") - if payload.device == "mobile": # noqa - "we cat set mobile token to be valid forever" - return payload + if not await cls.get(payload.user_id, token): + raise InvalidToken("Session token has expired, please try again") except DecodeError as e: raise InvalidToken("token format error") from e else: - if not await cls.exists(payload.user_id, token): - raise InvalidToken("Login expired, please login again") + if not await cls.get(payload.user_id, token): + raise InvalidToken("Session token has expired, please login again") return payload @classmethod - async def exists(cls, user_id, token): - return await TokenStorage.exist(f"{user_id}-{token}") + async def get(cls, uid, token): + return await TokenStorage.get(f"{uid}-{token}") class JWTAuthenticate(AuthenticationBackend): async def authenticate( self, request: HTTPConnection ) -> Optional[Tuple[AuthCredentials, AuthUser]]: - if JWT_AUTH_HEADER not in request.headers: + if SESSION_TOKEN_HEADER not in request.headers: return AuthCredentials(scopes=[]), AuthUser(user_id=None) - token = request.headers[JWT_AUTH_HEADER] + token = request.headers[SESSION_TOKEN_HEADER] try: - payload = await _Authenticate.verify(token) + payload = await SessionToken.verify(token) except Exception as exc: return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser( user_id=None @@ -67,9 +63,6 @@ class JWTAuthenticate(AuthenticationBackend): if payload is None: return AuthCredentials(scopes=[]), AuthUser(user_id=None) - if payload.device not in ("pc", "mobile"): - return AuthCredentials(scopes=[]), AuthUser(user_id=None) - user = await UserStorage.get_user(payload.user_id) if not user: return AuthCredentials(scopes=[]), AuthUser(user_id=None) @@ -81,55 +74,6 @@ class JWTAuthenticate(AuthenticationBackend): ) -class EmailAuthenticate: - @staticmethod - async def get_email_token(user): - token = await Authorize.authorize( - user, device="email", life_span=EMAIL_TOKEN_LIFE_SPAN - ) - return token - - @staticmethod - async def authenticate(token): - payload = await _Authenticate.verify(token) - if payload is None: - raise InvalidToken("invalid token") - if payload.device != "email": - raise InvalidToken("invalid token") - with local_session() as session: - user = session.query(User).filter_by(id=payload.user_id).first() - if not user: - raise Exception("user not exist") - if not user.emailConfirmed: - user.emailConfirmed = True - session.commit() - auth_token = await Authorize.authorize(user) - return (auth_token, user) - - -class ResetPassword: - @staticmethod - async def get_reset_token(user): - exp = datetime.utcnow() + timedelta(seconds=EMAIL_TOKEN_LIFE_SPAN) - token = JWTCodec.encode(user, exp=exp, device="pc") - await TokenStorage.save(f"{user.id}-reset-{token}", EMAIL_TOKEN_LIFE_SPAN, True) - return token - - @staticmethod - async def verify(token): - try: - payload = JWTCodec.decode(token) - except ExpiredSignatureError: - raise InvalidToken("Login expired, please login again") - except DecodeError as e: - raise InvalidToken("token format error") from e - else: - if not await TokenStorage.exist(f"{payload.user_id}-reset-{token}"): - raise InvalidToken("Login expired, please login again") - - return payload.user_id - - def login_required(func): @wraps(func) async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): diff --git a/auth/authorize.py b/auth/authorize.py deleted file mode 100644 index f9a538be..00000000 --- a/auth/authorize.py +++ /dev/null @@ -1,45 +0,0 @@ -from datetime import datetime, timedelta - -from auth.jwtcodec import JWTCodec -from base.redis import redis -from settings import JWT_LIFE_SPAN -from auth.validations import User - - -class TokenStorage: - @staticmethod - async def save(token_key, life_span, auto_delete=True): - await redis.execute("SET", token_key, "True") - if auto_delete: - expire_at = (datetime.now() + timedelta(seconds=life_span)).timestamp() - await redis.execute("EXPIREAT", token_key, int(expire_at)) - - @staticmethod - async def exist(token_key): - return await redis.execute("GET", token_key) - - -class Authorize: - @staticmethod - async def authorize( - user: User, device: str = "pc", life_span=JWT_LIFE_SPAN, auto_delete=True - ) -> str: - exp = datetime.utcnow() + timedelta(seconds=life_span) - token = JWTCodec.encode(user, exp=exp, device=device) - await TokenStorage.save(f"{user.id}-{token}", life_span, auto_delete) - return token - - @staticmethod - async def revoke(token: str) -> bool: - try: - payload = JWTCodec.decode(token) - except: # noqa - pass - else: - await redis.execute("DEL", f"{payload.user_id}-{token}") - return True - - @staticmethod - async def revoke_all(user: User): - tokens = await redis.execute("KEYS", f"{user.id}-*") - await redis.execute("DEL", *tokens) diff --git a/auth/credentials.py b/auth/credentials.py index 877c3d2b..5e2dfea9 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -1,6 +1,9 @@ from typing import List, Optional, Text + from pydantic import BaseModel +from base.exceptions import OperationNotAllowed + class Permission(BaseModel): name: Text @@ -17,7 +20,8 @@ class AuthCredentials(BaseModel): return True async def permissions(self) -> List[Permission]: - assert self.user_id is not None, "Please login first" + if self.user_id is not None: + raise OperationNotAllowed("Please login first") return NotImplemented() diff --git a/auth/email.py b/auth/email.py index 9e5c002f..2f62d041 100644 --- a/auth/email.py +++ b/auth/email.py @@ -1,84 +1,28 @@ -import requests -from starlette.responses import RedirectResponse -from auth.authenticate import EmailAuthenticate, ResetPassword -from base.orm import local_session -from settings import ( - BACKEND_URL, - MAILGUN_API_KEY, - MAILGUN_DOMAIN, - RESET_PWD_URL, - CONFIRM_EMAIL_URL, - ERROR_URL_ON_FRONTEND, -) - -MAILGUN_API_URL = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN) -MAILGUN_FROM = "discours.io " % (MAILGUN_DOMAIN) - -AUTH_URL = "%s/email_authorize" % (BACKEND_URL) - -email_templates = {"confirm_email": "", "auth_email": "", "reset_password_email": ""} - - -def load_email_templates(): - for name in email_templates: - filename = "auth/templates/%s.tmpl" % name - with open(filename) as f: - email_templates[name] = f.read() - print("[auth.email] templates loaded") - - -async def send_confirm_email(user): - text = email_templates["confirm_email"] - token = await EmailAuthenticate.get_email_token(user) - await send_email(user, AUTH_URL, text, token) - - -async def send_auth_email(user): - text = email_templates["auth_email"] - token = await EmailAuthenticate.get_email_token(user) - await send_email(user, AUTH_URL, text, token) - - -async def send_reset_password_email(user): - text = email_templates["reset_password_email"] - token = await ResetPassword.get_reset_token(user) - await send_email(user, RESET_PWD_URL, text, token) - - -async def send_email(user, url, text, token): - to = "%s <%s>" % (user.username, user.email) - url_with_token = "%s?token=%s" % (url, token) - text = text % (url_with_token) - response = requests.post( - MAILGUN_API_URL, - auth=("api", MAILGUN_API_KEY), - data={ - "from": MAILGUN_FROM, - "to": to, - "subject": "authorize log in", - "html": text, - }, - ) - response.raise_for_status() - - -async def email_authorize(request): - token = request.query_params.get("token") - if not token: - url_with_error = "%s?error=%s" % (ERROR_URL_ON_FRONTEND, "INVALID_TOKEN") - return RedirectResponse(url=url_with_error) - - try: - auth_token, user = await EmailAuthenticate.authenticate(token) - except: - url_with_error = "%s?error=%s" % (ERROR_URL_ON_FRONTEND, "INVALID_TOKEN") - return RedirectResponse(url=url_with_error) - - if not user.emailConfirmed: - with local_session() as session: - user.emailConfirmed = True - session.commit() - - response = RedirectResponse(url=CONFIRM_EMAIL_URL) - response.set_cookie("token", auth_token) - return response +import requests + +from settings import BACKEND_URL, MAILGUN_API_KEY, MAILGUN_DOMAIN + +MAILGUN_API_URL = "https://api.mailgun.net/v3/%s/messages" % MAILGUN_DOMAIN +MAILGUN_FROM = "discours.io " % MAILGUN_DOMAIN + + +async def send_auth_email(user, token): + text = """ + Follow the link to authorize + + """ + url = "%s/confirm_email" % BACKEND_URL + to = "%s <%s>" % (user.username, user.email) + url_with_token = "%s?token=%s" % (url, token) + text = text % url_with_token + response = requests.post( + MAILGUN_API_URL, + auth=("api", MAILGUN_API_KEY), + data={ + "from": MAILGUN_FROM, + "to": to, + "subject": "Confirm email", + "html": text, + }, + ) + response.raise_for_status() diff --git a/auth/identity.py b/auth/identity.py index 3c396274..d76ef160 100644 --- a/auth/identity.py +++ b/auth/identity.py @@ -1,16 +1,30 @@ -from auth.password import Password -from base.exceptions import InvalidPassword -from orm import User as OrmUser -from base.orm import local_session -from auth.validations import User - +from jwt import DecodeError, ExpiredSignatureError from sqlalchemy import or_ +from auth.jwtcodec import JWTCodec +from auth.tokenstorage import TokenStorage +from validations.auth import AuthInput +from base.exceptions import InvalidPassword +from base.exceptions import InvalidToken +from base.orm import local_session +from orm import User +from passlib.hash import bcrypt + + +class Password: + @staticmethod + def encode(password: str) -> str: + return bcrypt.hash(password) + + @staticmethod + def verify(password: str, other: str) -> bool: + return bcrypt.verify(password, other) + class Identity: @staticmethod - def identity(orm_user: OrmUser, password: str) -> User: - user = User(**orm_user.dict()) + def password(orm_user: User, password: str) -> User: + user = AuthInput(**orm_user.dict()) if not user.password: raise InvalidPassword("User password is empty") if not Password.verify(password, user.password): @@ -18,22 +32,37 @@ class Identity: return user @staticmethod - def identity_oauth(input) -> User: + def oauth(inp: AuthInput) -> User: with local_session() as session: user = ( - session.query(OrmUser) - .filter( - or_( - OrmUser.oauth == input["oauth"], OrmUser.email == input["email"] - ) - ) + session.query(User) + .filter(or_(User.oauth == inp["oauth"], User.email == inp["email"])) .first() ) if not user: - user = OrmUser.create(**input) + user = User.create(**inp) if not user.oauth: - user.oauth = input["oauth"] + user.oauth = inp["oauth"] session.commit() user = User(**user.dict()) return user + + @staticmethod + async def onetime(token: str) -> User: + try: + payload = JWTCodec.decode(token) + if not await TokenStorage.exist(f"{payload.user_id}-{token}"): + raise InvalidToken("Login token has expired, please login again") + except ExpiredSignatureError: + raise InvalidToken("Login token has expired, please try again") + except DecodeError as e: + raise InvalidToken("token format error") from e + with local_session() as session: + user = session.query(User).filter_by(id=payload.user_id).first() + if not user: + raise Exception("user not exist") + if not user.emailConfirmed: + user.emailConfirmed = True + session.commit() + return user diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index cc1bf9dd..608cb392 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -1,26 +1,29 @@ from datetime import datetime + import jwt + +from validations.auth import TokenPayload, AuthInput from settings import JWT_ALGORITHM, JWT_SECRET_KEY -from auth.validations import PayLoad, User class JWTCodec: @staticmethod - def encode(user: User, exp: datetime, device: str = "pc") -> str: + def encode(user: AuthInput, exp: datetime) -> str: payload = { "user_id": user.id, - "device": device, + # "user_email": user.email, # less secure + # "device": device, # no use cases "exp": exp, "iat": datetime.utcnow(), } return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM) @staticmethod - def decode(token: str, verify_exp: bool = True) -> PayLoad: + def decode(token: str, verify_exp: bool = True) -> TokenPayload: payload = jwt.decode( token, key=JWT_SECRET_KEY, options={"verify_exp": verify_exp}, algorithms=[JWT_ALGORITHM], ) - return PayLoad(**payload) + return TokenPayload(**payload) diff --git a/auth/oauth.py b/auth/oauth.py index 09600eea..afe18ecf 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -1,91 +1,89 @@ -from authlib.integrations.starlette_client import OAuth -from starlette.responses import RedirectResponse -from auth.authorize import Authorize -from auth.identity import Identity - -from settings import OAUTH_CLIENTS, BACKEND_URL, OAUTH_CALLBACK_URL - -oauth = OAuth() - -oauth.register( - name="facebook", - client_id=OAUTH_CLIENTS["FACEBOOK"]["id"], - client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"], - access_token_url="https://graph.facebook.com/v11.0/oauth/access_token", - access_token_params=None, - authorize_url="https://www.facebook.com/v11.0/dialog/oauth", - authorize_params=None, - api_base_url="https://graph.facebook.com/", - client_kwargs={"scope": "public_profile email"}, -) - -oauth.register( - name="github", - client_id=OAUTH_CLIENTS["GITHUB"]["id"], - client_secret=OAUTH_CLIENTS["GITHUB"]["key"], - access_token_url="https://github.com/login/oauth/access_token", - access_token_params=None, - authorize_url="https://github.com/login/oauth/authorize", - authorize_params=None, - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, -) - -oauth.register( - name="google", - client_id=OAUTH_CLIENTS["GOOGLE"]["id"], - client_secret=OAUTH_CLIENTS["GOOGLE"]["key"], - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, -) - - -async def google_profile(client, request, token): - profile = await client.parse_id_token(request, token) - profile["id"] = profile["sub"] - return profile - - -async def facebook_profile(client, request, token): - profile = await client.get("me?fields=name,id,email", token=token) - return profile.json() - - -async def github_profile(client, request, token): - profile = await client.get("user", token=token) - return profile.json() - - -profile_callbacks = { - "google": google_profile, - "facebook": facebook_profile, - "github": github_profile, -} - - -async def oauth_login(request): - provider = request.path_params["provider"] - request.session["provider"] = provider - client = oauth.create_client(provider) - redirect_uri = "%s/%s" % (BACKEND_URL, "oauth_authorize") - return await client.authorize_redirect(request, redirect_uri) - - -async def oauth_authorize(request): - provider = request.session["provider"] - client = oauth.create_client(provider) - token = await client.authorize_access_token(request) - get_profile = profile_callbacks[provider] - profile = await get_profile(client, request, token) - user_oauth_info = "%s:%s" % (provider, profile["id"]) - user_input = { - "oauth": user_oauth_info, - "email": profile["email"], - "username": profile["name"], - } - user = Identity.identity_oauth(user_input) - token = await Authorize.authorize(user, device="pc") - - response = RedirectResponse(url=OAUTH_CALLBACK_URL) - response.set_cookie("token", token) - return response +from authlib.integrations.starlette_client import OAuth +from starlette.responses import RedirectResponse +from auth.identity import Identity +from auth.tokenstorage import TokenStorage +from settings import OAUTH_CLIENTS, BACKEND_URL, OAUTH_CALLBACK_URL + +oauth = OAuth() + +oauth.register( + name="facebook", + client_id=OAUTH_CLIENTS["FACEBOOK"]["id"], + client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"], + access_token_url="https://graph.facebook.com/v11.0/oauth/access_token", + access_token_params=None, + authorize_url="https://www.facebook.com/v11.0/dialog/oauth", + authorize_params=None, + api_base_url="https://graph.facebook.com/", + client_kwargs={"scope": "public_profile email"}, +) + +oauth.register( + name="github", + client_id=OAUTH_CLIENTS["GITHUB"]["id"], + client_secret=OAUTH_CLIENTS["GITHUB"]["key"], + access_token_url="https://github.com/login/oauth/access_token", + access_token_params=None, + authorize_url="https://github.com/login/oauth/authorize", + authorize_params=None, + api_base_url="https://api.github.com/", + client_kwargs={"scope": "user:email"}, +) + +oauth.register( + name="google", + client_id=OAUTH_CLIENTS["GOOGLE"]["id"], + client_secret=OAUTH_CLIENTS["GOOGLE"]["key"], + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, +) + + +async def google_profile(client, request, token): + profile = await client.parse_id_token(request, token) + profile["id"] = profile["sub"] + return profile + + +async def facebook_profile(client, request, token): + profile = await client.get("me?fields=name,id,email", token=token) + return profile.json() + + +async def github_profile(client, request, token): + profile = await client.get("user", token=token) + return profile.json() + + +profile_callbacks = { + "google": google_profile, + "facebook": facebook_profile, + "github": github_profile, +} + + +async def oauth_login(request): + provider = request.path_params["provider"] + request.session["provider"] = provider + client = oauth.create_client(provider) + redirect_uri = "%s/%s" % (BACKEND_URL, "oauth_authorize") + return await client.authorize_redirect(request, redirect_uri) + + +async def oauth_authorize(request): + provider = request.session["provider"] + client = oauth.create_client(provider) + token = await client.authorize_access_token(request) + get_profile = profile_callbacks[provider] + profile = await get_profile(client, request, token) + user_oauth_info = "%s:%s" % (provider, profile["id"]) + user_input = { + "oauth": user_oauth_info, + "email": profile["email"], + "username": profile["name"], + } + user = Identity.oauth(user_input) + session_token = await TokenStorage.create_session(user) + response = RedirectResponse(url=OAUTH_CALLBACK_URL) + response.set_cookie("token", session_token) + return response diff --git a/auth/password.py b/auth/password.py deleted file mode 100644 index c084cd4e..00000000 --- a/auth/password.py +++ /dev/null @@ -1,11 +0,0 @@ -from passlib.hash import bcrypt - - -class Password: - @staticmethod - def encode(password: str) -> str: - return bcrypt.hash(password) - - @staticmethod - def verify(password: str, other: str) -> bool: - return bcrypt.verify(password, other) diff --git a/auth/templates/auth_email.tmpl b/auth/templates/auth_email.tmpl deleted file mode 100644 index 9135da9a..00000000 --- a/auth/templates/auth_email.tmpl +++ /dev/null @@ -1 +0,0 @@ -To enter the site follow the link diff --git a/auth/templates/confirm_email.tmpl b/auth/templates/confirm_email.tmpl deleted file mode 100644 index 9d56c41d..00000000 --- a/auth/templates/confirm_email.tmpl +++ /dev/null @@ -1 +0,0 @@ -To confirm registration follow the link diff --git a/auth/templates/reset_password_email.tmpl b/auth/templates/reset_password_email.tmpl deleted file mode 100644 index 51a68604..00000000 --- a/auth/templates/reset_password_email.tmpl +++ /dev/null @@ -1 +0,0 @@ -To reset password follow the link diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py new file mode 100644 index 00000000..482422a1 --- /dev/null +++ b/auth/tokenstorage.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta + +from auth.jwtcodec import JWTCodec +from validations.auth import AuthInput +from base.redis import redis +from settings import SESSION_TOKEN_LIFE_SPAN, ONETIME_TOKEN_LIFE_SPAN + + +async def save(token_key, life_span, auto_delete=True): + await redis.execute("SET", token_key, "True") + if auto_delete: + expire_at = (datetime.now() + timedelta(seconds=life_span)).timestamp() + await redis.execute("EXPIREAT", token_key, int(expire_at)) + + +class TokenStorage: + @staticmethod + async def get(token_key): + return await redis.execute("GET", token_key) + + @staticmethod + async def create_onetime(user: AuthInput) -> str: + life_span = ONETIME_TOKEN_LIFE_SPAN + exp = datetime.utcnow() + timedelta(seconds=life_span) + one_time_token = JWTCodec.encode(user, exp=exp) + await save(f"{user.id}-{one_time_token}", life_span) + return one_time_token + + @staticmethod + async def create_session(user: AuthInput) -> str: + life_span = SESSION_TOKEN_LIFE_SPAN + exp = datetime.utcnow() + timedelta(seconds=life_span) + session_token = JWTCodec.encode(user, exp=exp) + await save(f"{user.id}-{session_token}", life_span) + return session_token + + @staticmethod + async def revoke(token: str) -> bool: + try: + payload = JWTCodec.decode(token) + except: # noqa + pass + else: + await redis.execute("DEL", f"{payload.user_id}-{token}") + return True + + @staticmethod + async def revoke_all(user: AuthInput): + tokens = await redis.execute("KEYS", f"{user.id}-*") + await redis.execute("DEL", *tokens) diff --git a/auth/validations.py b/auth/validations.py deleted file mode 100644 index fc2b7353..00000000 --- a/auth/validations.py +++ /dev/null @@ -1,27 +0,0 @@ -from datetime import datetime -from typing import Optional, Text - -from pydantic import BaseModel - - -class User(BaseModel): - id: Optional[int] - # age: Optional[int] - username: Optional[Text] - # phone: Optional[Text] - password: Optional[Text] - - -class PayLoad(BaseModel): - user_id: int - device: Text - exp: datetime - iat: datetime - - -class CreateUser(BaseModel): - email: Text - username: Optional[Text] - # age: Optional[int] - # phone: Optional[Text] - password: Optional[Text] diff --git a/base/exceptions.py b/base/exceptions.py index 34c6455b..e08f933e 100644 --- a/base/exceptions.py +++ b/base/exceptions.py @@ -1,4 +1,4 @@ -from graphql import GraphQLError +from graphql.error import GraphQLError class BaseHttpException(GraphQLError): diff --git a/base/orm.py b/base/orm.py index d4d1c5ba..7ebb3ece 100644 --- a/base/orm.py +++ b/base/orm.py @@ -1,8 +1,10 @@ from typing import TypeVar, Any, Dict, Generic, Callable + from sqlalchemy import create_engine, Column, Integer from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import Session from sqlalchemy.sql.schema import Table + from settings import DB_URL if DB_URL.startswith("sqlite"): diff --git a/base/redis.py b/base/redis.py index 410a2713..97f6815b 100644 --- a/base/redis.py +++ b/base/redis.py @@ -1,34 +1,35 @@ -import aioredis -from settings import REDIS_URL - - -class Redis: - def __init__(self, uri=REDIS_URL): - self._uri: str = uri - self._instance = None - - async def connect(self): - if self._instance is not None: - return - self._instance = aioredis.from_url(self._uri, encoding="utf-8") - - async def disconnect(self): - if self._instance is None: - return - self._instance.close() - await self._instance.wait_closed() - self._instance = None - - async def execute(self, command, *args, **kwargs): - return await self._instance.execute_command(command, *args, **kwargs) - - async def lrange(self, key, start, stop): - return await self._instance.lrange(key, start, stop) - - async def mget(self, key, *keys): - return await self._instance.mget(key, *keys) - - -redis = Redis() - -__all__ = ["redis"] +import aioredis + +from settings import REDIS_URL + + +class Redis: + def __init__(self, uri=REDIS_URL): + self._uri: str = uri + self._instance = None + + async def connect(self): + if self._instance is not None: + return + self._instance = aioredis.from_url(self._uri, encoding="utf-8") + + async def disconnect(self): + if self._instance is None: + return + self._instance.close() + await self._instance.wait_closed() + self._instance = None + + async def execute(self, command, *args, **kwargs): + return await self._instance.execute_command(command, *args, **kwargs) + + async def lrange(self, key, start, stop): + return await self._instance.lrange(key, start, stop) + + async def mget(self, key, *keys): + return await self._instance.mget(key, *keys) + + +redis = Redis() + +__all__ = ["redis"] diff --git a/main.py b/main.py index b617c063..e3442fde 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ +import asyncio from importlib import import_module + from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL from starlette.applications import Starlette @@ -6,19 +8,18 @@ from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.routing import Route + from auth.authenticate import JWTAuthenticate from auth.oauth import oauth_login, oauth_authorize -from auth.email import email_authorize from base.redis import redis from base.resolvers import resolvers from resolvers.zine import ShoutsCache +from services.main import storages_init from services.stat.reacted import ReactedStorage +from services.stat.topicstat import TopicStat from services.stat.viewed import ViewedStorage from services.zine.gittask import GitTask -from services.stat.topicstat import TopicStat from services.zine.shoutauthor import ShoutAuthorStorage -import asyncio - import_module("resolvers") schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers) # type: ignore @@ -42,6 +43,8 @@ async def start_up(): print(topic_stat_task) git_task = asyncio.create_task(GitTask.git_task_worker()) print(git_task) + await storages_init() + print() async def shutdown(): @@ -51,7 +54,7 @@ async def shutdown(): routes = [ Route("/oauth/{provider}", endpoint=oauth_login), Route("/oauth_authorize", endpoint=oauth_authorize), - Route("/email_authorize", endpoint=email_authorize), + # Route("/confirm_email", endpoint=), # should be called on client ] app = Starlette( diff --git a/migration/__init__.py b/migration/__init__.py index 6f8e0430..2f99fe7b 100644 --- a/migration/__init__.py +++ b/migration/__init__.py @@ -1,33 +1,31 @@ """ cmd managed migration """ -import csv import asyncio -from datetime import datetime import json +import os import subprocess import sys -import os +from datetime import datetime + import bs4 -import numpy as np + +from migration.tables.comments import migrate as migrateComment +from migration.tables.comments import migrate_2stage as migrateComment_2stage +from migration.tables.content_items import get_shout_slug, migrate as migrateShout +from migration.tables.topics import migrate as migrateTopic +from migration.tables.users import migrate as migrateUser +from migration.tables.users import migrate_2stage as migrateUser_2stage +from orm.reaction import Reaction +from settings import DB_URL # from export import export_email_subscriptions from .export import export_mdx, export_slug -from orm.reaction import Reaction -from .tables.users import migrate as migrateUser -from .tables.users import migrate_2stage as migrateUser_2stage -from .tables.content_items import get_shout_slug, migrate as migrateShout -from .tables.topics import migrate as migrateTopic -from .tables.comments import migrate as migrateComment -from .tables.comments import migrate_2stage as migrateComment_2stage - -from settings import DB_URL - TODAY = datetime.strftime(datetime.now(), "%Y%m%d") OLD_DATE = "2016-03-05 22:22:00.350000" -def users_handle(storage): +async def users_handle(storage): """migrating users first""" counter = 0 id_map = {} @@ -47,10 +45,9 @@ def users_handle(storage): ce = 0 for entry in storage["users"]["data"]: ce += migrateUser_2stage(entry, id_map) - return storage -def topics_handle(storage): +async def topics_handle(storage): """topics from categories and tags""" counter = 0 for t in storage["topics"]["tags"] + storage["topics"]["cats"]: @@ -78,8 +75,6 @@ def topics_handle(storage): + str(len(storage["topics"]["by_slug"].values())) + " topics by slug" ) - # raise Exception - return storage async def shouts_handle(storage, args): @@ -105,9 +100,9 @@ async def shouts_handle(storage, args): if not shout["topics"]: print("[migration] no topics!") - # wuth author - author = shout["authors"][0].slug - if author == "discours": + # with author + author: str = shout["authors"][0].dict() + if author["slug"] == "discours": discours_author += 1 # print('[migration] ' + shout['slug'] + ' with author ' + author) @@ -118,21 +113,21 @@ async def shouts_handle(storage, args): # print main counter counter += 1 - line = str(counter + 1) + ": " + shout["slug"] + " @" + author + line = str(counter + 1) + ": " + shout["slug"] + " @" + author["slug"] print(line) + b = bs4.BeautifulSoup(shout["body"], "html.parser") - texts = [] - texts.append(shout["title"].lower().replace(r"[^а-яА-Яa-zA-Z]", "")) - texts = b.findAll(text=True) + texts = [shout["title"].lower().replace(r"[^а-яА-Яa-zA-Z]", "")] + texts = texts + b.findAll(text=True) topics_dataset_bodies.append(" ".join([x.strip().lower() for x in texts])) topics_dataset_tlist.append(shout["topics"]) - # np.savetxt('topics_dataset.csv', (topics_dataset_bodies, topics_dataset_tlist), delimiter=',', fmt='%s') + # np.savetxt('topics_dataset.csv', (topics_dataset_bodies, topics_dataset_tlist), delimiter=', + # ', fmt='%s') print("[migration] " + str(counter) + " content items were migrated") print("[migration] " + str(pub_counter) + " have been published") print("[migration] " + str(discours_author) + " authored by @discours") - return storage async def comments_handle(storage): @@ -146,9 +141,9 @@ async def comments_handle(storage): missed_shouts[reaction] = oldcomment elif type(reaction) == Reaction: reaction = reaction.dict() - id = reaction["id"] + rid = reaction["id"] oid = reaction["oid"] - id_map[oid] = id + id_map[oid] = rid else: ignored_counter += 1 @@ -161,7 +156,6 @@ async def comments_handle(storage): for missed in missed_shouts.values(): missed_counter += len(missed) print("[migration] " + str(missed_counter) + " comments dropped") - return storage def bson_handle(): @@ -180,8 +174,8 @@ def export_one(slug, storage, args=None): async def all_handle(storage, args): print("[migration] handle everything") - users_handle(storage) - topics_handle(storage) + await users_handle(storage) + await topics_handle(storage) await shouts_handle(storage, args) await comments_handle(storage) # export_email_subscriptions() @@ -205,11 +199,6 @@ def data_load(): "users": {"by_oid": {}, "by_slug": {}, "data": []}, "replacements": json.loads(open("migration/tables/replacements.json").read()), } - users_data = [] - tags_data = [] - cats_data = [] - comments_data = [] - content_data = [] try: users_data = json.loads(open("migration/data/users.json").read()) print("[migration.load] " + str(len(users_data)) + " users ") @@ -265,13 +254,13 @@ def data_load(): + str(len(storage["reactions"]["by_content"].keys())) + " with comments" ) + storage["users"]["data"] = users_data + storage["topics"]["tags"] = tags_data + storage["topics"]["cats"] = cats_data + storage["shouts"]["data"] = content_data + storage["reactions"]["data"] = comments_data except Exception as e: raise e - storage["users"]["data"] = users_data - storage["topics"]["tags"] = tags_data - storage["topics"]["cats"] = cats_data - storage["shouts"]["data"] = content_data - storage["reactions"]["data"] = comments_data return storage @@ -301,7 +290,7 @@ def create_pgdump(): async def handle_auto(): - print("[migration] no command given, auto mode") + print("[migration] no option given, auto mode") url = os.getenv("MONGODB_URL") if url: mongo_download(url) diff --git a/migration/bson2json.py b/migration/bson2json.py index 82ff2281..77538d4d 100644 --- a/migration/bson2json.py +++ b/migration/bson2json.py @@ -1,6 +1,7 @@ -import os -import bson import json +import os + +import bson from .utils import DateTimeEncoder diff --git a/migration/export.py b/migration/export.py index 37e01099..98655f87 100644 --- a/migration/export.py +++ b/migration/export.py @@ -1,7 +1,9 @@ -from datetime import datetime import json import os +from datetime import datetime + import frontmatter + from .extract import extract_html, prepare_html_body from .utils import DateTimeEncoder @@ -67,22 +69,40 @@ def export_slug(slug, storage): def export_email_subscriptions(): - email_subscriptions_data = json.loads(open("migration/data/email_subscriptions.json").read()) + email_subscriptions_data = json.loads( + open("migration/data/email_subscriptions.json").read() + ) for data in email_subscriptions_data: # TODO: migrate to mailgun list manually # migrate_email_subscription(data) pass - print("[migration] " + str(len(email_subscriptions_data)) + " email subscriptions exported") + print( + "[migration] " + + str(len(email_subscriptions_data)) + + " email subscriptions exported" + ) def export_shouts(storage): # update what was just migrated or load json again if len(storage["users"]["by_slugs"].keys()) == 0: - storage["users"]["by_slugs"] = json.loads(open(EXPORT_DEST + "authors.json").read()) - print("[migration] " + str(len(storage["users"]["by_slugs"].keys())) + " exported authors ") + storage["users"]["by_slugs"] = json.loads( + open(EXPORT_DEST + "authors.json").read() + ) + print( + "[migration] " + + str(len(storage["users"]["by_slugs"].keys())) + + " exported authors " + ) if len(storage["shouts"]["by_slugs"].keys()) == 0: - storage["shouts"]["by_slugs"] = json.loads(open(EXPORT_DEST + "articles.json").read()) - print("[migration] " + str(len(storage["shouts"]["by_slugs"].keys())) + " exported articles ") + storage["shouts"]["by_slugs"] = json.loads( + open(EXPORT_DEST + "articles.json").read() + ) + print( + "[migration] " + + str(len(storage["shouts"]["by_slugs"].keys())) + + " exported articles " + ) for slug in storage["shouts"]["by_slugs"].keys(): export_slug(slug, storage) @@ -130,4 +150,8 @@ def export_json( ensure_ascii=False, ) ) - print("[migration] " + str(len(export_comments.items())) + " exported articles with comments") + print( + "[migration] " + + str(len(export_comments.items())) + + " exported articles with comments" + ) diff --git a/migration/extract.py b/migration/extract.py index 2ae6da50..558e56ef 100644 --- a/migration/extract.py +++ b/migration/extract.py @@ -1,6 +1,7 @@ +import base64 import os import re -import base64 + from .html2text import html2text TOOLTIP_REGEX = r"(\/\/\/(.+)\/\/\/)" diff --git a/migration/html2text/__init__.py b/migration/html2text/__init__.py index 0160b303..1090025c 100644 --- a/migration/html2text/__init__.py +++ b/migration/html2text/__init__.py @@ -379,16 +379,16 @@ class HTML2Text(html.parser.HTMLParser): if start: if ( self.current_class == "highlight" - and self.inheader == False - and self.span_lead == False - and self.astack == False + and not self.inheader + and not self.span_lead + and not self.astack ): self.o("`") # NOTE: same as self.span_highlight = True elif ( self.current_class == "lead" - and self.inheader == False - and self.span_highlight == False + and not self.inheader + and not self.span_highlight ): # self.o("==") # NOTE: CriticMarkup {== self.span_lead = True diff --git a/migration/html2text/cli.py b/migration/html2text/cli.py index d0c62c97..dbaba28b 100644 --- a/migration/html2text/cli.py +++ b/migration/html2text/cli.py @@ -4,6 +4,7 @@ import sys from . import HTML2Text, __version__, config +# noinspection DuplicatedCode def main() -> None: baseurl = "" diff --git a/migration/html2text/utils.py b/migration/html2text/utils.py index 366748b6..1cf22b52 100644 --- a/migration/html2text/utils.py +++ b/migration/html2text/utils.py @@ -68,13 +68,11 @@ def element_style( :rtype: dict """ style = parent_style.copy() - if "class" in attrs: - assert attrs["class"] is not None + if attrs.get("class"): for css_class in attrs["class"].split(): css_style = style_def.get("." + css_class, {}) style.update(css_style) - if "style" in attrs: - assert attrs["style"] is not None + if attrs.get("style"): immediate_style = dumb_property_dict(attrs["style"]) style.update(immediate_style) @@ -149,8 +147,7 @@ def list_numbering_start(attrs: Dict[str, Optional[str]]) -> int: :rtype: int or None """ - if "start" in attrs: - assert attrs["start"] is not None + if attrs.get("start"): try: return int(attrs["start"]) - 1 except ValueError: diff --git a/migration/tables/comments.py b/migration/tables/comments.py index 26e48f57..4be7335f 100644 --- a/migration/tables/comments.py +++ b/migration/tables/comments.py @@ -1,8 +1,10 @@ from datetime import datetime + from dateutil.parser import parse as date_parse -from orm import Reaction, User + from base.orm import local_session from migration.html2text import html2text +from orm import Reaction, User from orm.reaction import ReactionKind from services.stat.reacted import ReactedStorage @@ -46,16 +48,13 @@ async def migrate(entry, storage): old_thread: String } """ - reaction_dict = {} - reaction_dict["createdAt"] = ( - ts if not entry.get("createdAt") else date_parse(entry.get("createdAt")) - ) - print("[migration] reaction original date %r" % entry.get("createdAt")) - # print('[migration] comment date %r ' % comment_dict['createdAt']) - reaction_dict["body"] = html2text(entry.get("body", "")) - reaction_dict["oid"] = entry["_id"] - if entry.get("createdAt"): - reaction_dict["createdAt"] = date_parse(entry.get("createdAt")) + reaction_dict = { + "createdAt": ( + ts if not entry.get("createdAt") else date_parse(entry.get("createdAt")) + ), + "body": html2text(entry.get("body", "")), + "oid": entry["_id"], + } shout_oid = entry.get("contentItem") if shout_oid not in storage["shouts"]["by_oid"]: if len(storage["shouts"]["by_oid"]) > 0: @@ -126,7 +125,7 @@ def migrate_2stage(rr, old_new_id): with local_session() as session: comment = session.query(Reaction).filter(Reaction.id == new_id).first() comment.replyTo = old_new_id.get(reply_oid) - comment.save() + session.add(comment) session.commit() if not rr["body"]: raise Exception(rr) diff --git a/migration/tables/content_items.py b/migration/tables/content_items.py index 461e0c5c..ff42eae3 100644 --- a/migration/tables/content_items.py +++ b/migration/tables/content_items.py @@ -1,14 +1,18 @@ -from dateutil.parser import parse as date_parse -import sqlalchemy -from orm.shout import Shout, ShoutTopic, User -from services.stat.reacted import ReactedStorage -from services.stat.viewed import ViewedByDay -from transliterate import translit from datetime import datetime + +from dateutil.parser import parse as date_parse +from sqlalchemy.exc import IntegrityError +from transliterate import translit + from base.orm import local_session from migration.extract import prepare_html_body from orm.community import Community from orm.reaction import Reaction, ReactionKind +from orm.shout import Shout, ShoutTopic, User +from orm.topic import TopicFollower +from services.stat.reacted import ReactedStorage +from services.stat.viewed import ViewedByDay +from services.zine.topics import TopicStorage OLD_DATE = "2016-03-05 22:22:00.350000" ts = datetime.now() @@ -72,7 +76,10 @@ async def migrate(entry, storage): } else: userdata = User.default_user.dict() - assert userdata, "no user found for %s from %d" % [oid, len(users_by_oid.keys())] + if not userdata: + raise Exception( + "no user found for %s from %d" % [oid, len(users_by_oid.keys())] + ) r["authors"] = [ userdata, ] @@ -139,32 +146,40 @@ async def migrate(entry, storage): # del shout_dict['rating'] # NOTE: TypeError: 'rating' is an invalid keyword argument for Shout # del shout_dict['ratings'] email = userdata.get("email") - slug = userdata.get("slug") - if not slug: + userslug = userdata.get("slug") + if not userslug: raise Exception with local_session() as session: # c = session.query(Community).all().pop() if email: user = session.query(User).filter(User.email == email).first() - if not user and slug: - user = session.query(User).filter(User.slug == slug).first() + if not user and userslug: + user = session.query(User).filter(User.slug == userslug).first() if not user and userdata: try: userdata["slug"] = userdata["slug"].lower().strip().replace(" ", "-") user = User.create(**userdata) - except sqlalchemy.exc.IntegrityError: + except IntegrityError: print("[migration] user error: " + userdata) userdata["id"] = user.id userdata["createdAt"] = user.createdAt storage["users"]["by_slug"][userdata["slug"]] = userdata storage["users"]["by_oid"][entry["_id"]] = userdata - assert user, "could not get a user" - shout_dict["authors"] = [user, ] + if not user: + raise Exception("could not get a user") + shout_dict["authors"] = [ + user, + ] # TODO: subscribe shout user on shout topics try: s = Shout.create(**shout_dict) - except sqlalchemy.exc.IntegrityError as e: + with local_session() as session: + topics = session.query(ShoutTopic).where(ShoutTopic.shout == s.slug).all() + for tpc in topics: + TopicFollower.create(topic=tpc.slug, follower=userslug) + await TopicStorage.update_topic(tpc.slug) + except IntegrityError as e: with local_session() as session: s = session.query(Shout).filter(Shout.slug == shout_dict["slug"]).first() bump = False @@ -267,9 +282,9 @@ async def migrate(entry, storage): ) reaction.update(reaction_dict) else: - reaction_dict["day"] = ( - reaction_dict.get("createdAt") or ts - ).replace(hour=0, minute=0, second=0, microsecond=0) + # day = ( + # reaction_dict.get("createdAt") or ts + # ).replace(hour=0, minute=0, second=0, microsecond=0) rea = Reaction.create(**reaction_dict) await ReactedStorage.react(rea) # shout_dict['ratings'].append(reaction_dict) diff --git a/migration/tables/replacements.json b/migration/tables/replacements.json index e53a0886..e47bb837 100644 --- a/migration/tables/replacements.json +++ b/migration/tables/replacements.json @@ -764,5 +764,37 @@ "blocked-in-russia": "blocked-in-russia", "kavarga": "kavarga", "galereya-anna-nova": "gallery-anna-nova", - "derrida": "derrida" -} \ No newline at end of file + "derrida": "derrida", + "dinozavry": "dinosaurs", + "beecake": "beecake", + "literaturnyykaver": "literature-cover", + "dialog": "dialogue", + "dozhd": "rain", + "pomosch": "help", + "igra": "game", + "reportazh-1": "reportage", + "armiya-1": "army", + "ukraina-2": "ukraine", + "nasilie-1": "violence", + "smert-1": "death", + "dnevnik-1": "dairy", + "voyna-na-ukraine": "war-in-ukraine", + "zabota": "care", + "ango": "ango", + "hayku": "haiku", + "utrata": "loss", + "pokoy": "peace", + "kladbische": "cemetery", + "lomonosov": "lomonosov", + "istoriya-nauki": "history-of-sceince", + "sud": "court", + "russkaya-toska": "russian-toska", + "duh": "spirit", + "devyanostye": "90s", + "seksualnoe-nasilie": "sexual-violence", + "gruziya-2": "georgia", + "dokumentalnaya-poeziya": "documentary-poetry", + "kriptovalyuty": "cryptocurrencies", + "magiya": "magic", + "yazychestvo": "paganism" +} diff --git a/migration/tables/topics.py b/migration/tables/topics.py index 3db0cbe9..bda4ba97 100644 --- a/migration/tables/topics.py +++ b/migration/tables/topics.py @@ -1,5 +1,5 @@ -from migration.extract import extract_md, html2text from base.orm import local_session +from migration.extract import extract_md, html2text from orm import Topic, Community diff --git a/migration/tables/users.py b/migration/tables/users.py index 4d0127d7..8631454f 100644 --- a/migration/tables/users.py +++ b/migration/tables/users.py @@ -1,8 +1,9 @@ -import sqlalchemy +from dateutil.parser import parse +from sqlalchemy.exc import IntegrityError + +from base.orm import local_session from migration.html2text import html2text from orm import User, UserRating -from dateutil.parser import parse -from base.orm import local_session def migrate(entry): @@ -21,9 +22,6 @@ def migrate(entry): "muted": False, # amnesty "bio": entry["profile"].get("bio", ""), "notifications": [], - "createdAt": parse(entry["createdAt"]), - "roles": [], # entry['roles'] # roles by community - "ratings": [], # entry['ratings'] "links": [], "name": "anonymous", } @@ -86,7 +84,7 @@ def migrate(entry): user_dict["slug"] = user_dict["slug"].lower().strip().replace(" ", "-") try: user = User.create(**user_dict.copy()) - except sqlalchemy.exc.IntegrityError: + except IntegrityError: print("[migration] cannot create user " + user_dict["slug"]) with local_session() as session: old_user = ( @@ -120,28 +118,10 @@ def migrate_2stage(entry, id_map): with local_session() as session: try: user_rating = UserRating.create(**user_rating_dict) - except sqlalchemy.exc.IntegrityError: - old_rating = ( - session.query(UserRating) - .filter(UserRating.rater == rater_slug) - .first() - ) - print( - "[migration] cannot create " - + author_slug - + "`s rate from " - + rater_slug - ) - print( - "[migration] concat rating value %d+%d=%d" - % ( - old_rating.value, - rating_entry["value"], - old_rating.value + rating_entry["value"], - ) - ) - old_rating.update({"value": old_rating.value + rating_entry["value"]}) + session.add(user_rating) session.commit() + except IntegrityError: + print("[migration] cannot rate " + author_slug + "`s by " + rater_slug) except Exception as e: print(e) return ce diff --git a/orm/__init__.py b/orm/__init__.py index f14f4450..9c11262b 100644 --- a/orm/__init__.py +++ b/orm/__init__.py @@ -1,41 +1,29 @@ -from orm.rbac import Operation, Resource, Permission, Role -from services.auth.roles import RoleStorage -from orm.community import Community -from orm.user import User, UserRating -from orm.topic import Topic, TopicFollower -from orm.notification import Notification -from orm.shout import Shout -from orm.reaction import Reaction -from services.stat.reacted import ReactedStorage -from services.zine.topics import TopicStorage -from services.auth.users import UserStorage -from services.stat.viewed import ViewedStorage -from base.orm import Base, engine, local_session - -__all__ = [ - "User", - "Role", - "Operation", - "Permission", - "Community", - "Shout", - "Topic", - "TopicFollower", - "Notification", - "Reaction", - "UserRating", -] - -Base.metadata.create_all(engine) -Operation.init_table() -Resource.init_table() -User.init_table() -Community.init_table() -Role.init_table() - -with local_session() as session: - ViewedStorage.init(session) - ReactedStorage.init(session) - RoleStorage.init(session) - UserStorage.init(session) - TopicStorage.init(session) +from base.orm import Base, engine +from orm.community import Community +from orm.notification import Notification +from orm.rbac import Operation, Resource, Permission, Role +from orm.reaction import Reaction +from orm.shout import Shout +from orm.topic import Topic, TopicFollower +from orm.user import User, UserRating + +__all__ = [ + "User", + "Role", + "Operation", + "Permission", + "Community", + "Shout", + "Topic", + "TopicFollower", + "Notification", + "Reaction", + "UserRating", +] + +Base.metadata.create_all(engine) +Operation.init_table() +Resource.init_table() +User.init_table() +Community.init_table() +Role.init_table() diff --git a/orm/collab.py b/orm/collab.py index f8a58d1e..4b205134 100644 --- a/orm/collab.py +++ b/orm/collab.py @@ -1,5 +1,7 @@ from datetime import datetime + from sqlalchemy import Boolean, Column, String, ForeignKey, DateTime + from base.orm import Base diff --git a/orm/collection.py b/orm/collection.py index 7d0a0b44..e6e3d004 100644 --- a/orm/collection.py +++ b/orm/collection.py @@ -1,5 +1,7 @@ from datetime import datetime + from sqlalchemy import Column, String, ForeignKey, DateTime + from base.orm import Base diff --git a/orm/community.py b/orm/community.py index ab4d319a..90ba6a8e 100644 --- a/orm/community.py +++ b/orm/community.py @@ -1,5 +1,7 @@ from datetime import datetime + from sqlalchemy import Column, String, ForeignKey, DateTime + from base.orm import Base, local_session diff --git a/orm/notification.py b/orm/notification.py index 84af44e3..75468802 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, String, JSON as JSONType + from base.orm import Base diff --git a/orm/rbac.py b/orm/rbac.py index be69a131..903585a3 100644 --- a/orm/rbac.py +++ b/orm/rbac.py @@ -1,6 +1,8 @@ import warnings + from sqlalchemy import String, Column, ForeignKey, UniqueConstraint, TypeDecorator from sqlalchemy.orm import relationship + from base.orm import Base, REGISTRY, engine, local_session from orm.community import Community diff --git a/orm/reaction.py b/orm/reaction.py index 30069d1d..69b3b37a 100644 --- a/orm/reaction.py +++ b/orm/reaction.py @@ -1,7 +1,9 @@ from datetime import datetime + from sqlalchemy import Column, String, ForeignKey, DateTime -from base.orm import Base from sqlalchemy import Enum + +from base.orm import Base from services.stat.reacted import ReactedStorage, ReactionKind from services.stat.viewed import ViewedStorage diff --git a/orm/shout.py b/orm/shout.py index d431431a..f4620bf0 100644 --- a/orm/shout.py +++ b/orm/shout.py @@ -1,12 +1,14 @@ from datetime import datetime + from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship -from orm.user import User -from orm.topic import Topic, ShoutTopic + +from base.orm import Base from orm.reaction import Reaction +from orm.topic import Topic, ShoutTopic +from orm.user import User from services.stat.reacted import ReactedStorage from services.stat.viewed import ViewedStorage -from base.orm import Base class ShoutReactionsFollower(Base): diff --git a/orm/topic.py b/orm/topic.py index 1ac861fa..69094606 100644 --- a/orm/topic.py +++ b/orm/topic.py @@ -1,5 +1,7 @@ from datetime import datetime + from sqlalchemy import Column, String, ForeignKey, DateTime, JSON as JSONType + from base.orm import Base diff --git a/orm/user.py b/orm/user.py index d45a2b29..764cc539 100644 --- a/orm/user.py +++ b/orm/user.py @@ -1,4 +1,5 @@ from datetime import datetime + from sqlalchemy import ( Column, Integer, @@ -9,6 +10,7 @@ from sqlalchemy import ( JSON as JSONType, ) from sqlalchemy.orm import relationship + from base.orm import Base, local_session from orm.rbac import Role from services.auth.roles import RoleStorage diff --git a/requirements.txt b/requirements.txt index a786a9c7..1b29c20e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,25 @@ -frontmatter -numpy -aioredis -ariadne +python-frontmatter~=1.0.0 +aioredis~=2.0.1 +ariadne>=0.16.0 +PyYAML>=5.4 pyjwt>=2.0.0 -starlette -sqlalchemy -uvicorn -pydantic -passlib +starlette~=0.20.4 +sqlalchemy>=1.4.41 +graphql-core +uvicorn>=0.18.3 +pydantic>=1.10.2 +passlib~=1.7.4 itsdangerous -authlib==0.15.5 +authlib>=1.1.0 httpx>=0.23.0 psycopg2-binary -transliterate -requests -bcrypt +transliterate~=1.10.2 +requests~=2.28.1 +bcrypt>=4.0.0 websockets -bson +bson~=0.5.10 flake8 +DateTime~=4.7 +asyncio~=3.4.3 +python-dateutil~=2.8.2 +beautifulsoup4~=4.11.1 diff --git a/resolvers/__init__.py b/resolvers/__init__.py index c27e607d..8af58039 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -3,9 +3,42 @@ from resolvers.auth import ( sign_out, is_email_used, register, - confirm, - auth_forget, - auth_reset, + confirm_email, + auth_send_link, +) +from resolvers.collab import remove_author, invite_author +from resolvers.community import ( + create_community, + delete_community, + get_community, + get_communities, +) + +# from resolvers.collab import invite_author, remove_author +from resolvers.editor import create_shout, delete_shout, update_shout +from resolvers.profile import ( + get_users_by_slugs, + get_current_user, + get_user_reacted_shouts, + get_user_roles, + get_top_authors, +) + +# from resolvers.feed import shouts_for_feed, my_candidates +from resolvers.reactions import ( + create_reaction, + delete_reaction, + update_reaction, + reactions_unfollow, + reactions_follow, + get_shout_reactions, +) +from resolvers.topics import ( + topic_follow, + topic_unfollow, + topics_by_author, + topics_by_community, + topics_all, ) from resolvers.zine import ( get_shout_by_slug, @@ -21,36 +54,6 @@ from resolvers.zine import ( shouts_by_topics, shouts_by_communities, ) -from resolvers.profile import ( - get_users_by_slugs, - get_current_user, - get_user_reacted_shouts, - get_user_roles, - get_top_authors -) -from resolvers.topics import ( - topic_follow, - topic_unfollow, - topics_by_author, - topics_by_community, - topics_all, -) - -# from resolvers.feed import shouts_for_feed, my_candidates -from resolvers.reactions import ( - create_reaction, - delete_reaction, - update_reaction, -) - -# from resolvers.collab import invite_author, remove_author -from resolvers.editor import create_shout, delete_shout, update_shout -from resolvers.community import ( - create_community, - delete_community, - get_community, - get_communities, -) __all__ = [ "follow", @@ -59,9 +62,8 @@ __all__ = [ "login", "register", "is_email_used", - "confirm", - "auth_forget", - "auth_reset", + "confirm_email", + "auth_send_link", "sign_out", # profile "get_current_user", @@ -69,10 +71,7 @@ __all__ = [ "get_user_roles", "get_top_authors", # zine - "shouts_for_feed", - "my_candidates", "recent_published", - "recent_reacted", "recent_all", "shouts_by_topics", "shouts_by_authors", @@ -82,7 +81,6 @@ __all__ = [ "top_overall", "top_viewed", "view_shout", - "view_reaction", "get_shout_by_slug", # editor "create_shout", diff --git a/resolvers/auth.py b/resolvers/auth.py index eafcf620..4b9ceeea 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -1,29 +1,42 @@ -from graphql import GraphQLResolveInfo -from transliterate import translit from urllib.parse import quote_plus -from auth.authenticate import login_required, ResetPassword -from auth.authorize import Authorize -from auth.identity import Identity -from auth.password import Password -from auth.email import send_confirm_email, send_auth_email, send_reset_password_email -from orm import User, Role + +from auth.tokenstorage import TokenStorage +from graphql.type import GraphQLResolveInfo +from transliterate import translit + +from auth.authenticate import login_required +from auth.email import send_auth_email +from auth.identity import Identity, Password +from base.exceptions import ( + InvalidPassword, + InvalidToken, + ObjectNotExist, + OperationNotAllowed, +) from base.orm import local_session from base.resolvers import mutation, query +from orm import User, Role from resolvers.profile import get_user_info -from base.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, OperationNotAllowed -from settings import JWT_AUTH_HEADER +from settings import SESSION_TOKEN_HEADER @mutation.field("confirmEmail") -async def confirm(*_, confirm_token): +async def confirm_email(*_, confirm_token): """confirm owning email address""" - auth_token, user = await Authorize.confirm(confirm_token) - if auth_token: - user.emailConfirmed = True - user.save() - return {"token": auth_token, "user": user} - else: - # not an error, warns user + user_id = None + try: + user_id = await TokenStorage.get(confirm_token) + with local_session() as session: + user = session.query(User).where(User.id == user_id).first() + session_token = TokenStorage.create_session(user) + user.emailConfirmed = True + session.add(user) + session.commit() + return {"token": session_token, "user": user} + except InvalidToken as e: + raise InvalidToken(e.message) + except Exception as e: + print(e) # FIXME: debug only return {"error": "email not confirmed"} @@ -50,40 +63,21 @@ async def register(*_, email: str, password: str = ""): session.add(user) session.commit() - await send_confirm_email(user) + token = await TokenStorage.create_onetime(user) + await send_auth_email(user, token) return {"user": user} -@mutation.field("requestPasswordUpdate") -async def auth_forget(_, info, email): - """send email to recover account""" +@mutation.field("sendLink") +async def auth_send_link(_, info, email): + """send link with confirm code to email""" with local_session() as session: user = session.query(User).filter(User.email == email).first() if not user: raise ObjectNotExist("User not found") - - await send_reset_password_email(user) - - return {} - - -@mutation.field("updatePassword") -async def auth_reset(_, info, password, resetToken): - """set the new password""" - try: - user_id = await ResetPassword.verify(resetToken) - except InvalidToken as e: - raise InvalidToken(e.message) - # return {"error": e.message} - - with local_session() as session: - user = session.query(User).filter_by(id=user_id).first() - if not user: - raise ObjectNotExist("User not found") - user.password = Password.encode(password) - session.commit() - + token = await TokenStorage.create_onetime(user) + await send_auth_email(user, token) return {} @@ -92,48 +86,44 @@ async def login(_, info: GraphQLResolveInfo, email: str, password: str = ""): with local_session() as session: orm_user = session.query(User).filter(User.email == email).first() - if orm_user is None: - print(f"signIn {email}: email not found") - # return {"error": "email not found"} - raise ObjectNotExist("User not found") + if orm_user is None: + print(f"[auth] {email}: email not found") + # return {"error": "email not found"} + raise ObjectNotExist("User not found") # contains webserver status - if not password: - print(f"signIn {email}: send auth email") - await send_auth_email(orm_user) - return {""} + if not password: + print(f"[auth] send confirm link to {email}") + token = await TokenStorage.create_onetime(orm_user) + await send_auth_email(orm_user, token) + # FIXME: not an error, warning + return {"error": "no password, email link was sent"} - if not orm_user.emailConfirmed: - # not an error, warns users - return {"error": "email not confirmed"} - - try: - device = info.context["request"].headers["device"] - except KeyError: - device = "pc" - auto_delete = False if device == "mobile" else True # why autodelete with mobile? - - try: - user = Identity.identity(orm_user, password) - except InvalidPassword: - print(f"signIn {email}: invalid password") - raise InvalidPassword("invalid passoword") - # return {"error": "invalid password"} - - token = await Authorize.authorize(user, device=device, auto_delete=auto_delete) - print(f"signIn {email}: OK") - - return { - "token": token, - "user": orm_user, - "info": await get_user_info(orm_user.slug), - } + else: + # sign in using password + if not orm_user.emailConfirmed: + # not an error, warns users + return {"error": "please, confirm email"} + else: + try: + user = Identity.password(orm_user, password) + session_token = await TokenStorage.create_session(user) + print(f"[auth] user {email} authorized") + return { + "token": session_token, + "user": user, + "info": await get_user_info(user.slug), + } + except InvalidPassword: + print(f"[auth] {email}: invalid password") + raise InvalidPassword("invalid passoword") # contains webserver status + # return {"error": "invalid password"} @query.field("signOut") @login_required async def sign_out(_, info: GraphQLResolveInfo): - token = info.context["request"].headers[JWT_AUTH_HEADER] - status = await Authorize.revoke(token) + token = info.context["request"].headers[SESSION_TOKEN_HEADER] + status = await TokenStorage.revoke(token) return status diff --git a/resolvers/collab.py b/resolvers/collab.py index 67927a3d..8271b478 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -1,10 +1,11 @@ from datetime import datetime + +from auth.authenticate import login_required from base.orm import local_session +from base.resolvers import query, mutation from orm.collab import Collab from orm.shout import Shout from orm.user import User -from base.resolvers import query, mutation -from auth.authenticate import login_required @query.field("getCollabs") @@ -12,11 +13,10 @@ from auth.authenticate import login_required async def get_collabs(_, info): auth = info.context["request"].auth user_id = auth.user_id - collabs = [] with local_session() as session: user = session.query(User).where(User.id == user_id).first() collabs = session.query(Collab).filter(user.slug in Collab.authors) - return collabs + return collabs @mutation.field("inviteAuthor") @@ -37,7 +37,7 @@ async def invite_author(_, info, author, shout): return {"error": "already added"} shout.authors.append(author) shout.updated_at = datetime.now() - shout.save() + session.add(shout) session.commit() # TODO: email notify @@ -63,7 +63,7 @@ async def remove_author(_, info, author, shout): return {"error": "not in authors"} shout.authors.remove(author) shout.updated_at = datetime.now() - shout.save() + session.add(shout) session.commit() # result = Result("INVITED") diff --git a/resolvers/collection.py b/resolvers/collection.py index 3755da14..2987b459 100644 --- a/resolvers/collection.py +++ b/resolvers/collection.py @@ -1,11 +1,13 @@ -from orm.collection import Collection, ShoutCollection -from base.orm import local_session -from orm.user import User -from base.resolvers import mutation, query -from auth.authenticate import login_required from datetime import datetime + from sqlalchemy import and_ +from auth.authenticate import login_required +from base.orm import local_session +from base.resolvers import mutation, query +from orm.collection import Collection, ShoutCollection +from orm.user import User + @mutation.field("createCollection") @login_required @@ -27,7 +29,7 @@ async def create_collection(_, _info, inp): async def update_collection(_, info, inp): auth = info.context["request"].auth user_id = auth.user_id - collection_slug = input.get("slug", "") + collection_slug = inp.get("slug", "") with local_session() as session: owner = session.query(User).filter(User.id == user_id) # note list here collection = ( @@ -57,6 +59,7 @@ async def delete_collection(_, info, slug): if collection.owner != user_id: return {"error": "access denied"} collection.deletedAt = datetime.now() + session.add(collection) session.commit() return {} diff --git a/resolvers/community.py b/resolvers/community.py index c18635ba..e1bc41bb 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -1,12 +1,14 @@ -from orm.community import Community, CommunityFollower -from base.orm import local_session -from orm.user import User -from base.resolvers import mutation, query -from auth.authenticate import login_required from datetime import datetime from typing import List + from sqlalchemy import and_ +from auth.authenticate import login_required +from base.orm import local_session +from base.resolvers import mutation, query +from orm.community import Community, CommunityFollower +from orm.user import User + @mutation.field("createCommunity") @login_required @@ -23,6 +25,8 @@ async def create_community(_, info, input): createdBy=user.slug, createdAt=datetime.now(), ) + session.add(community) + session.commit() return {"community": community} @@ -48,6 +52,7 @@ async def update_community(_, info, input): community.desc = input.get("desc", "") community.pic = input.get("pic", "") community.updatedAt = datetime.now() + session.add(community) session.commit() @@ -64,6 +69,7 @@ async def delete_community(_, info, slug): if community.owner != user_id: return {"error": "access denied"} community.deletedAt = datetime.now() + session.add(community) session.commit() return {} diff --git a/resolvers/editor.py b/resolvers/editor.py index ed007193..5d8b6aad 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -1,37 +1,38 @@ -from orm import Shout +from datetime import datetime + +from auth.authenticate import login_required from base.orm import local_session +from base.resolvers import mutation +from orm import Shout from orm.rbac import Resource from orm.shout import ShoutAuthor, ShoutTopic from orm.user import User -from base.resolvers import mutation from resolvers.reactions import reactions_follow, reactions_unfollow -from auth.authenticate import login_required -from datetime import datetime from services.zine.gittask import GitTask @mutation.field("createShout") @login_required -async def create_shout(_, info, input): +async def create_shout(_, info, inp): user = info.context["request"].user - topic_slugs = input.get("topic_slugs", []) + topic_slugs = inp.get("topic_slugs", []) if topic_slugs: - del input["topic_slugs"] + del inp["topic_slugs"] - new_shout = Shout.create(**input) + new_shout = Shout.create(**inp) ShoutAuthor.create(shout=new_shout.slug, user=user.slug) reactions_follow(user, new_shout.slug, True) - if "mainTopic" in input: - topic_slugs.append(input["mainTopic"]) + if "mainTopic" in inp: + topic_slugs.append(inp["mainTopic"]) for slug in topic_slugs: ShoutTopic.create(shout=new_shout.slug, topic=slug) new_shout.topic_slugs = topic_slugs - GitTask(input, user.username, user.email, "new shout %s" % (new_shout.slug)) + GitTask(inp, user.username, user.email, "new shout %s" % (new_shout.slug)) # await ShoutCommentsStorage.send_shout(new_shout) @@ -40,11 +41,11 @@ async def create_shout(_, info, input): @mutation.field("updateShout") @login_required -async def update_shout(_, info, input): +async def update_shout(_, info, inp): auth = info.context["request"].auth user_id = auth.user_id - slug = input["slug"] + slug = inp["slug"] session = local_session() user = session.query(User).filter(User.id == user_id).first() @@ -60,15 +61,16 @@ async def update_shout(_, info, input): if Resource.shout_id not in scopes: return {"error": "access denied"} - shout.update(input) + shout.update(inp) shout.updatedAt = datetime.now() + session.add(shout) session.commit() session.close() - for topic in input.get("topic_slugs", []): + for topic in inp.get("topic_slugs", []): ShoutTopic.create(shout=slug, topic=topic) - GitTask(input, user.username, user.email, "update shout %s" % (slug)) + GitTask(inp, user.username, user.email, "update shout %s" % (slug)) return {"shout": shout} @@ -89,6 +91,7 @@ async def delete_shout(_, info, slug): for a in authors: reactions_unfollow(a.slug, slug, True) shout.deletedAt = datetime.now() + session.add(shout) session.commit() return {} diff --git a/resolvers/feed.py b/resolvers/feed.py index 3f90aadd..3a15abd6 100644 --- a/resolvers/feed.py +++ b/resolvers/feed.py @@ -1,11 +1,13 @@ +from typing import List + +from sqlalchemy import and_, desc + from auth.authenticate import login_required from base.orm import local_session from base.resolvers import query -from sqlalchemy import and_, desc from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import TopicFollower from orm.user import AuthorFollower -from typing import List from services.zine.shoutscache import prepare_shouts @@ -22,14 +24,14 @@ def get_user_feed(_, info, offset, limit) -> List[Shout]: .where(AuthorFollower.follower == user.slug) .order_by(desc(Shout.createdAt)) ) - topicrows = ( + topic_rows = ( session.query(Shout) .join(ShoutTopic) .join(TopicFollower) .where(TopicFollower.follower == user.slug) .order_by(desc(Shout.createdAt)) ) - shouts = shouts.union(topicrows).limit(limit).offset(offset).all() + shouts = shouts.union(topic_rows).limit(limit).offset(offset).all() return shouts @@ -37,7 +39,6 @@ def get_user_feed(_, info, offset, limit) -> List[Shout]: @login_required async def user_unpublished_shouts(_, info, offset, limit) -> List[Shout]: user = info.context["request"].user - shouts = [] with local_session() as session: shouts = prepare_shouts( session.query(Shout) @@ -48,4 +49,4 @@ async def user_unpublished_shouts(_, info, offset, limit) -> List[Shout]: .offset(offset) .all() ) - return shouts + return shouts diff --git a/resolvers/inbox.py b/resolvers/inbox.py index 3d779c33..e38f616d 100644 --- a/resolvers/inbox.py +++ b/resolvers/inbox.py @@ -1,10 +1,11 @@ -from base.resolvers import mutation, query, subscription -from auth.authenticate import login_required import asyncio -import uuid import json +import uuid from datetime import datetime + +from auth.authenticate import login_required from base.redis import redis +from base.resolvers import mutation, query, subscription class ChatFollowing: diff --git a/resolvers/profile.py b/resolvers/profile.py index 13486c48..7df3d910 100644 --- a/resolvers/profile.py +++ b/resolvers/profile.py @@ -1,18 +1,21 @@ from datetime import datetime -from orm.user import User, UserRole, Role, UserRating, AuthorFollower -from services.auth.users import UserStorage -from orm.shout import Shout -from orm.reaction import Reaction -from base.orm import local_session -from orm.topic import Topic, TopicFollower -from base.resolvers import mutation, query -from resolvers.community import get_followed_communities -from resolvers.reactions import get_shout_reactions -from auth.authenticate import login_required -from resolvers.inbox import get_unread_counter +from typing import List + from sqlalchemy import and_, desc from sqlalchemy.orm import selectinload -from typing import List + +from auth.authenticate import login_required +from auth.tokenstorage import TokenStorage +from base.orm import local_session +from base.resolvers import mutation, query +from orm.reaction import Reaction +from orm.shout import Shout +from orm.topic import Topic, TopicFollower +from orm.user import User, UserRole, Role, UserRating, AuthorFollower +from resolvers.community import get_followed_communities +from resolvers.inbox import get_unread_counter +from resolvers.reactions import get_shout_reactions +from services.auth.users import UserStorage @query.field("userReactedShouts") @@ -87,12 +90,13 @@ async def get_user_info(slug): @login_required async def get_current_user(_, info): user = info.context["request"].user + user.lastSeen = datetime.now() with local_session() as session: - user.lastSeen = datetime.now() - user.save() + session.add(user) session.commit() + token = await TokenStorage.create_session(user) return { - "token": "", # same token? + "token": token, "user": user, "info": await get_user_info(user.slug), } @@ -133,7 +137,8 @@ async def update_profile(_, info, profile): user = session.query(User).filter(User.id == user_id).first() if user: User.update(user, **profile) - session.commit() + session.add(user) + session.commit() return {} diff --git a/resolvers/reactions.py b/resolvers/reactions.py index 9b33b9d4..e59437c9 100644 --- a/resolvers/reactions.py +++ b/resolvers/reactions.py @@ -1,11 +1,13 @@ +from datetime import datetime + from sqlalchemy import desc -from orm.reaction import Reaction + +from auth.authenticate import login_required from base.orm import local_session +from base.resolvers import mutation, query +from orm.reaction import Reaction from orm.shout import ShoutReactionsFollower from orm.user import User -from base.resolvers import mutation, query -from auth.authenticate import login_required -from datetime import datetime from services.auth.users import UserStorage from services.stat.reacted import ReactedStorage diff --git a/resolvers/topics.py b/resolvers/topics.py index 45e3cc53..b25a69a8 100644 --- a/resolvers/topics.py +++ b/resolvers/topics.py @@ -1,12 +1,14 @@ -from orm.topic import Topic, TopicFollower -from services.zine.topics import TopicStorage -from services.stat.topicstat import TopicStat +import random + +from sqlalchemy import and_ + +from auth.authenticate import login_required from base.orm import local_session from base.resolvers import mutation, query -from auth.authenticate import login_required -from sqlalchemy import and_ -import random +from orm.topic import Topic, TopicFollower +from services.stat.topicstat import TopicStat from services.zine.shoutscache import ShoutsCache +from services.zine.topics import TopicStorage @query.field("topicsAll") @@ -60,7 +62,7 @@ async def update_topic(_, _info, inp): async def topic_follow(user, slug): - TopicFollower.create(follower=user.slug, topic=slug) + TopicFollower.create(topic=slug, follower=user.slug) await TopicStorage.update_topic(slug) diff --git a/resolvers/zine.py b/resolvers/zine.py index a0e9ea9f..ac5a1616 100644 --- a/resolvers/zine.py +++ b/resolvers/zine.py @@ -1,18 +1,19 @@ +from sqlalchemy.orm import selectinload +from sqlalchemy.sql.expression import and_, select, desc + +from auth.authenticate import login_required +from base.orm import local_session +from base.resolvers import mutation, query from orm.collection import ShoutCollection from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic -from base.orm import local_session -from base.resolvers import mutation, query +from resolvers.community import community_follow, community_unfollow +from resolvers.profile import author_follow, author_unfollow +from resolvers.reactions import reactions_follow, reactions_unfollow +from resolvers.topics import topic_follow, topic_unfollow +from services.stat.viewed import ViewedStorage from services.zine.shoutauthor import ShoutAuthorStorage from services.zine.shoutscache import ShoutsCache -from services.stat.viewed import ViewedStorage -from resolvers.profile import author_follow, author_unfollow -from resolvers.topics import topic_follow, topic_unfollow -from resolvers.community import community_follow, community_unfollow -from resolvers.reactions import reactions_follow, reactions_unfollow -from auth.authenticate import login_required -from sqlalchemy import select, desc, asc, and_ -from sqlalchemy.orm import selectinload @mutation.field("incrementView") @@ -33,6 +34,12 @@ async def top_month(_, _info, offset, limit): return ShoutsCache.top_month[offset : offset + limit] +@query.field("topCommented") +async def top_commented(_, _info, offset, limit): + async with ShoutsCache.lock: + return ShoutsCache.top_commented[offset : offset + limit] + + @query.field("topOverall") async def top_overall(_, _info, offset, limit): async with ShoutsCache.lock: @@ -105,7 +112,7 @@ async def get_search_results(_, _info, query, offset, limit): for s in shouts: for a in s.authors: a.caption = await ShoutAuthorStorage.get_author_caption(s.slug, a.slug) - s.stat.search = 1 # FIXME + s.stat.relevance = 1 # FIXME return shouts @@ -116,7 +123,7 @@ async def shouts_by_topics(_, _info, slugs, offset, limit): session.query(Shout) .join(ShoutTopic) .where(and_(ShoutTopic.topic.in_(slugs), bool(Shout.publishedAt))) - .order_by(asc(Shout.publishedAt)) + .order_by(desc(Shout.publishedAt)) .limit(limit) .offset(offset) ) @@ -134,7 +141,7 @@ async def shouts_by_collection(_, _info, collection, offset, limit): session.query(Shout) .join(ShoutCollection, ShoutCollection.collection == collection) .where(and_(ShoutCollection.shout == Shout.slug, bool(Shout.publishedAt))) - .order_by(asc(Shout.publishedAt)) + .order_by(desc(Shout.publishedAt)) .limit(limit) .offset(offset) ) @@ -151,7 +158,7 @@ async def shouts_by_authors(_, _info, slugs, offset, limit): session.query(Shout) .join(ShoutAuthor) .where(and_(ShoutAuthor.user.in_(slugs), bool(Shout.publishedAt))) - .order_by(asc(Shout.publishedAt)) + .order_by(desc(Shout.publishedAt)) .limit(limit) .offset(offset) ) @@ -184,7 +191,7 @@ async def shouts_by_communities(_, info, slugs, offset, limit): ), ) ) - .order_by(desc(Shout.publishedAt)) + .order_by(desc("publishedAt")) .limit(limit) .offset(offset) ) diff --git a/schema.graphql b/schema.graphql index 7285bd2f..e21e0aca 100644 --- a/schema.graphql +++ b/schema.graphql @@ -148,11 +148,10 @@ type Mutation { markAsRead(chatId: String!, ids: [Int]!): Result! # auth - confirmEmail(token: String!): AuthResult! refreshSession: AuthResult! registerUser(email: String!, password: String): AuthResult! - requestPasswordUpdate(email: String!): Result! - updatePassword(password: String!, token: String!): Result! + sendLink(email: String!): Result! + confirmEmail(code: String!): AuthResult! # shout createShout(input: ShoutInput!): Result! @@ -237,6 +236,7 @@ type Query { topAuthors(offset: Int!, limit: Int!): [Author]! topMonth(offset: Int!, limit: Int!): [Shout]! topOverall(offset: Int!, limit: Int!): [Shout]! + topCommented(offset: Int!, limit: Int!): [Shout]! recentPublished(offset: Int!, limit: Int!): [Shout]! # homepage recentReacted(offset: Int!, limit: Int!): [Shout]! # test recentAll(offset: Int!, limit: Int!): [Shout]! diff --git a/server.py b/server.py index d0c90b93..f2ae2b44 100644 --- a/server.py +++ b/server.py @@ -1,30 +1,29 @@ -import uvicorn -from settings import PORT - -import sys - -if __name__ == "__main__": - x = "" - if len(sys.argv) > 1: - x = sys.argv[1] - if x == "dev": - print("DEV MODE") - headers = [ - ("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"), - ("Access-Control-Allow-Origin", "http://localhost:3000"), - ( - "Access-Control-Allow-Headers", - "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range", - ), - ("Access-Control-Expose-Headers", "Content-Length,Content-Range"), - ("Access-Control-Allow-Credentials", "true"), - ] - uvicorn.run( - "main:app", host="localhost", port=8080, headers=headers - ) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt", reload=True) - elif x == "migrate": - from migration import migrate - - migrate() - else: - uvicorn.run("main:app", host="0.0.0.0", port=PORT) +import sys +import uvicorn +from settings import PORT + +if __name__ == "__main__": + x = "" + if len(sys.argv) > 1: + x = sys.argv[1] + if x == "dev": + print("DEV MODE") + headers = [ + ("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"), + ("Access-Control-Allow-Origin", "http://localhost:3000"), + ( + "Access-Control-Allow-Headers", + "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range", + ), + ("Access-Control-Expose-Headers", "Content-Length,Content-Range"), + ("Access-Control-Allow-Credentials", "true"), + ] + uvicorn.run( + "main:app", host="localhost", port=8080, headers=headers + ) # , ssl_keyfile="discours.key", ssl_certfile="discours.crt", reload=True) + elif x == "migrate": + from migration import migrate + + migrate() + else: + uvicorn.run("main:app", host="0.0.0.0", port=PORT) diff --git a/services/auth/roles.py b/services/auth/roles.py index 3f342df6..b5924dc5 100644 --- a/services/auth/roles.py +++ b/services/auth/roles.py @@ -1,5 +1,7 @@ import asyncio + from sqlalchemy.orm import selectinload + from orm.rbac import Role diff --git a/services/auth/users.py b/services/auth/users.py index dad50043..489155ce 100644 --- a/services/auth/users.py +++ b/services/auth/users.py @@ -1,5 +1,7 @@ import asyncio + from sqlalchemy.orm import selectinload + from orm.user import User diff --git a/services/main.py b/services/main.py new file mode 100644 index 00000000..4f4c824f --- /dev/null +++ b/services/main.py @@ -0,0 +1,17 @@ +from services.stat.viewed import ViewedStorage +from services.stat.reacted import ReactedStorage +from services.auth.roles import RoleStorage +from services.auth.users import UserStorage +from services.zine.topics import TopicStorage +from base.orm import local_session + + +async def storages_init(): + with local_session() as session: + print('[main] initialize storages') + ViewedStorage.init(session) + ReactedStorage.init(session) + RoleStorage.init(session) + UserStorage.init(session) + TopicStorage.init(session) + session.commit() diff --git a/services/stat/reacted.py b/services/stat/reacted.py index 60bfe1a4..748834e2 100644 --- a/services/stat/reacted.py +++ b/services/stat/reacted.py @@ -1,11 +1,13 @@ import asyncio from datetime import datetime +from enum import Enum as Enumeration + from sqlalchemy import Column, DateTime, ForeignKey, Boolean from sqlalchemy.orm.attributes import flag_modified +from sqlalchemy.types import Enum as ColumnEnum + from base.orm import Base, local_session from orm.topic import ShoutTopic -from enum import Enum as Enumeration -from sqlalchemy.types import Enum as ColumnEnum class ReactionKind(Enumeration): @@ -139,26 +141,23 @@ class ReactedStorage: self = ReactedStorage async with self.lock: - reactions = self.reacted["shouts"].get(reaction.shout) - if reaction.replyTo: - reactions = self.reacted["reactions"].get(reaction.id) - for r in reactions.values(): - r = { - "day": datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 - ), - "reaction": reaction.id, - "kind": reaction.kind, - "shout": reaction.shout, - } - if reaction.replyTo: - r["replyTo"] = reaction.replyTo - if reaction.body: - r["comment"] = True - reaction: ReactedByDay = ReactedByDay.create(**r) # type: ignore - self.reacted["shouts"][reaction.shout] = self.reacted["shouts"].get( - reaction.shout, [] - ) + reactions = {} + + # iterate sibling reactions + reactions = self.reacted["shouts"].get(reaction.shout, {}) + for r in reactions.values(): + reaction = ReactedByDay.create({ + "day": datetime.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ), + "reaction": r.id, + "kind": r.kind, + "shout": r.shout, + "comment": bool(r.body), + "replyTo": r.replyTo + }) + # renew sorted by shouts store + self.reacted["shouts"][reaction.shout] = self.reacted["shouts"].get(reaction.shout, []) self.reacted["shouts"][reaction.shout].append(reaction) if reaction.replyTo: self.reacted["reaction"][reaction.replyTo] = self.reacted[ @@ -169,11 +168,12 @@ class ReactedStorage: "reactions" ].get(reaction.replyTo, 0) + kind_to_rate(reaction.kind) else: + # rate only by root reactions on shout self.rating["shouts"][reaction.replyTo] = self.rating["shouts"].get( reaction.shout, 0 ) + kind_to_rate(reaction.kind) - flag_modified(r, "value") + flag_modified(reaction, "value") @staticmethod def init(session): @@ -218,16 +218,20 @@ class ReactedStorage: async def flush_changes(session): self = ReactedStorage async with self.lock: - for slug in dict(self.reacted['shouts']).keys(): - topics = session.query(ShoutTopic.topic).where(ShoutTopic.shout == slug).all() - reactions = self.reacted['shouts'].get(slug, []) + for slug in dict(self.reacted["shouts"]).keys(): + topics = ( + session.query(ShoutTopic.topic) + .where(ShoutTopic.shout == slug) + .all() + ) + reactions = self.reacted["shouts"].get(slug, []) # print('[stat.reacted] shout {' + str(slug) + "}: " + str(len(reactions))) for ts in list(topics): tslug = ts[0] topic_reactions = self.reacted["topics"].get(tslug, []) topic_reactions += reactions # print('[stat.reacted] topic {' + str(tslug) + "}: " + str(len(topic_reactions))) - reactions += list(self.reacted['reactions'].values()) + reactions += list(self.reacted["reactions"].values()) for reaction in reactions: if getattr(reaction, "modified", False): session.add(reaction) diff --git a/services/stat/topicstat.py b/services/stat/topicstat.py index cd50d5a5..e1ef7c59 100644 --- a/services/stat/topicstat.py +++ b/services/stat/topicstat.py @@ -1,19 +1,11 @@ import asyncio + from base.orm import local_session from orm.shout import Shout +from orm.topic import ShoutTopic, TopicFollower from services.stat.reacted import ReactedStorage from services.stat.viewed import ViewedStorage from services.zine.shoutauthor import ShoutAuthorStorage -from orm.topic import ShoutTopic, TopicFollower - - -def unique(list1): - - # insert the list to the set - list_set = set(list1) - # convert the set to the list - unique_list = (list(list_set)) - return unique_list class TopicStat: @@ -27,7 +19,7 @@ class TopicStat: async def load_stat(session): self = TopicStat shout_topics = session.query(ShoutTopic).all() - print('[stat.topics] shout topics amount', len(shout_topics)) + print("[stat.topics] shout topics amount", len(shout_topics)) for shout_topic in shout_topics: # shouts by topics @@ -35,7 +27,11 @@ class TopicStat: shout = shout_topic.shout sss = set(self.shouts_by_topic.get(topic, [])) shout = session.query(Shout).where(Shout.slug == shout).first() - sss.union([shout, ]) + sss.union( + [ + shout, + ] + ) self.shouts_by_topic[topic] = list(sss) # authors by topics diff --git a/services/stat/viewed.py b/services/stat/viewed.py index df5f29d8..59eba9fc 100644 --- a/services/stat/viewed.py +++ b/services/stat/viewed.py @@ -1,7 +1,9 @@ import asyncio from datetime import datetime + from sqlalchemy import Column, DateTime, ForeignKey, Integer from sqlalchemy.orm.attributes import flag_modified + from base.orm import Base, local_session from orm.topic import ShoutTopic diff --git a/services/zine/gittask.py b/services/zine/gittask.py index f9523573..31e55025 100644 --- a/services/zine/gittask.py +++ b/services/zine/gittask.py @@ -1,6 +1,7 @@ +import asyncio import subprocess from pathlib import Path -import asyncio + from settings import SHOUTS_REPO diff --git a/services/zine/shoutauthor.py b/services/zine/shoutauthor.py index e1e50f82..f4c8728a 100644 --- a/services/zine/shoutauthor.py +++ b/services/zine/shoutauthor.py @@ -1,4 +1,5 @@ import asyncio + from base.orm import local_session from orm.shout import ShoutAuthor diff --git a/services/zine/shoutscache.py b/services/zine/shoutscache.py index 1c39f18d..eda6b8e3 100644 --- a/services/zine/shoutscache.py +++ b/services/zine/shoutscache.py @@ -1,7 +1,9 @@ import asyncio from datetime import datetime, timedelta + from sqlalchemy import and_, desc, func, select from sqlalchemy.orm import selectinload + from base.orm import local_session from orm.reaction import Reaction from orm.shout import Shout, ShoutAuthor, ShoutTopic @@ -27,6 +29,7 @@ class ShoutsCache: top_month = [] top_overall = [] top_viewed = [] + top_commented = [] by_author = {} by_topic = {} @@ -34,14 +37,17 @@ class ShoutsCache: @staticmethod async def prepare_recent_published(): with local_session() as session: - shouts = await prepare_shouts(session, ( - select(Shout) - .options(selectinload(Shout.authors), selectinload(Shout.topics)) - .where(bool(Shout.publishedAt)) - .group_by(Shout.slug) - .order_by(desc("publishedAt")) - .limit(ShoutsCache.limit) - )) + shouts = await prepare_shouts( + session, + ( + select(Shout) + .options(selectinload(Shout.authors), selectinload(Shout.topics)) + .where(bool(Shout.publishedAt)) + .group_by(Shout.slug) + .order_by(desc("publishedAt")) + .limit(ShoutsCache.limit) + ), + ) async with ShoutsCache.lock: ShoutsCache.recent_published = shouts print("[zine.cache] %d recently published shouts " % len(shouts)) @@ -49,14 +55,17 @@ class ShoutsCache: @staticmethod async def prepare_recent_all(): with local_session() as session: - shouts = await prepare_shouts(session, ( - select(Shout) - .options(selectinload(Shout.authors), selectinload(Shout.topics)) - .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) - .group_by(Shout.slug) - .order_by(desc("createdAt")) - .limit(ShoutsCache.limit) - )) + shouts = await prepare_shouts( + session, + ( + select(Shout) + .options(selectinload(Shout.authors), selectinload(Shout.topics)) + .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("createdAt")) + .limit(ShoutsCache.limit) + ), + ) async with ShoutsCache.lock: ShoutsCache.recent_all = shouts print("[zine.cache] %d recently created shouts " % len(shouts)) @@ -64,18 +73,23 @@ class ShoutsCache: @staticmethod async def prepare_recent_reacted(): with local_session() as session: - shouts = await prepare_shouts(session, ( - select(Shout, func.max(Reaction.createdAt).label("reactionCreatedAt")) - .options( - selectinload(Shout.authors), - selectinload(Shout.topics), - ) - .join(Reaction, Reaction.shout == Shout.slug) - .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) - .group_by(Shout.slug) - .order_by(desc("reactionCreatedAt")) - .limit(ShoutsCache.limit) - )) + shouts = await prepare_shouts( + session, + ( + select( + Shout, func.max(Reaction.createdAt).label("reactionCreatedAt") + ) + .options( + selectinload(Shout.authors), + selectinload(Shout.topics), + ) + .join(Reaction, Reaction.shout == Shout.slug) + .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("reactionCreatedAt")) + .limit(ShoutsCache.limit) + ), + ) async with ShoutsCache.lock: ShoutsCache.recent_reacted = shouts print("[zine.cache] %d recently reacted shouts " % len(shouts)) @@ -84,20 +98,23 @@ class ShoutsCache: async def prepare_top_overall(): with local_session() as session: # with reacted times counter - shouts = await prepare_shouts(session, ( - select(Shout, func.count(Reaction.id).label("reacted")) - .options( - selectinload(Shout.authors), - selectinload(Shout.topics), - selectinload(Shout.reactions), - ) - .join(Reaction) - .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) - .group_by(Shout.slug) - .order_by(desc("reacted")) - .limit(ShoutsCache.limit) - )) - shouts.sort(key=lambda s: s.stats['rating'], reverse=True) + shouts = await prepare_shouts( + session, + ( + select(Shout, func.count(Reaction.id).label("reacted")) + .options( + selectinload(Shout.authors), + selectinload(Shout.topics), + selectinload(Shout.reactions), + ) + .join(Reaction) + .where(and_(bool(Shout.publishedAt), bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("reacted")) + .limit(ShoutsCache.limit) + ), + ) + shouts.sort(key=lambda s: s.stats["rating"], reverse=True) async with ShoutsCache.lock: print("[zine.cache] %d top shouts " % len(shouts)) ShoutsCache.top_overall = shouts @@ -106,34 +123,61 @@ class ShoutsCache: async def prepare_top_month(): month_ago = datetime.now() - timedelta(days=30) with local_session() as session: - shouts = await prepare_shouts(session, ( - select(Shout, func.count(Reaction.id).label("reacted")) - .options(selectinload(Shout.authors), selectinload(Shout.topics)) - .join(Reaction) - .where(and_(Shout.createdAt > month_ago, bool(Reaction.deletedAt))) - .group_by(Shout.slug) - .order_by(desc("reacted")) - .limit(ShoutsCache.limit) - )) - shouts.sort(key=lambda s: s.stats['rating'], reverse=True) + shouts = await prepare_shouts( + session, + ( + select(Shout, func.count(Reaction.id).label("reacted")) + .options(selectinload(Shout.authors), selectinload(Shout.topics)) + .join(Reaction) + .where(and_(Shout.createdAt > month_ago, bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("reacted")) + .limit(ShoutsCache.limit) + ), + ) + shouts.sort(key=lambda s: s.stats["rating"], reverse=True) async with ShoutsCache.lock: print("[zine.cache] %d top month shouts " % len(shouts)) ShoutsCache.top_month = shouts + @staticmethod + async def prepare_top_commented(): + month_ago = datetime.now() - timedelta(days=30) + with local_session() as session: + shouts = await prepare_shouts( + session, + ( + select(Shout, Reaction) + .options(selectinload(Shout.authors), selectinload(Shout.topics)) + .join(Reaction) + .where(and_(Shout.createdAt > month_ago, bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("commented")) + .limit(ShoutsCache.limit) + ), + ) + shouts.sort(key=lambda s: s.stats["commented"], reverse=True) + async with ShoutsCache.lock: + print("[zine.cache] %d top commented shouts " % len(shouts)) + ShoutsCache.top_viewed = shouts + @staticmethod async def prepare_top_viewed(): month_ago = datetime.now() - timedelta(days=30) with local_session() as session: - shouts = await prepare_shouts(session, ( - select(Shout, func.sum(ViewedByDay.value).label("viewed")) - .options(selectinload(Shout.authors), selectinload(Shout.topics)) - .join(ViewedByDay) - .where(and_(Shout.createdAt > month_ago, bool(Reaction.deletedAt))) - .group_by(Shout.slug) - .order_by(desc("viewed")) - .limit(ShoutsCache.limit) - )) - shouts.sort(key=lambda s: s.stats['viewed'], reverse=True) + shouts = await prepare_shouts( + session, + ( + select(Shout, func.sum(ViewedByDay.value).label("viewed")) + .options(selectinload(Shout.authors), selectinload(Shout.topics)) + .join(ViewedByDay) + .where(and_(Shout.createdAt > month_ago, bool(Reaction.deletedAt))) + .group_by(Shout.slug) + .order_by(desc("viewed")) + .limit(ShoutsCache.limit) + ), + ) + shouts.sort(key=lambda s: s.stats["viewed"], reverse=True) async with ShoutsCache.lock: print("[zine.cache] %d top viewed shouts " % len(shouts)) ShoutsCache.top_viewed = shouts diff --git a/settings.py b/settings.py index 394b36d1..6dc07ef0 100644 --- a/settings.py +++ b/settings.py @@ -5,8 +5,7 @@ INBOX_SERVICE_PORT = 8081 BACKEND_URL = environ.get("BACKEND_URL") or "https://localhost:8080" OAUTH_CALLBACK_URL = environ.get("OAUTH_CALLBACK_URL") or "https://localhost:8080" -RESET_PWD_URL = environ.get("RESET_PWD_URL") or "https://localhost:8080/reset_pwd" -CONFIRM_EMAIL_URL = environ.get("CONFIRM_EMAIL_URL") or "https://new.discours.io" +CONFIRM_EMAIL_URL = environ.get("AUTH_CONFIRM_URL") or BACKEND_URL + "/confirm" ERROR_URL_ON_FRONTEND = ( environ.get("ERROR_URL_ON_FRONTEND") or "https://new.discours.io" ) @@ -17,9 +16,9 @@ DB_URL = ( ) JWT_ALGORITHM = "HS256" JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key" -JWT_LIFE_SPAN = 24 * 60 * 60 # seconds -JWT_AUTH_HEADER = "Auth" -EMAIL_TOKEN_LIFE_SPAN = 1 * 60 * 60 # seconds +SESSION_TOKEN_HEADER = "Auth" +SESSION_TOKEN_LIFE_SPAN = 24 * 60 * 60 # seconds +ONETIME_TOKEN_LIFE_SPAN = 1 * 60 * 60 # seconds REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" MAILGUN_API_KEY = environ.get("MAILGUN_API_KEY") diff --git a/validations/auth.py b/validations/auth.py new file mode 100644 index 00000000..f22bdfc8 --- /dev/null +++ b/validations/auth.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Optional, Text + +from pydantic import BaseModel + + +class AuthInput(BaseModel): + id: Optional[int] + username: Optional[Text] + password: Optional[Text] + + +class TokenPayload(BaseModel): + user_id: int + exp: datetime + iat: datetime