initial-draft

This commit is contained in:
Untone 2023-11-24 01:58:55 +03:00
parent 7304735041
commit ec20a4ebcd
22 changed files with 996 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
venv
.DS_Store
__pycache__
.idea
.vscode
poetry.lock

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Use an official Python runtime as a parent image
FROM python:slim
# Set the working directory in the container to /app
WORKDIR /app
# Add metadata to the image to describe that the container is listening on port 80
EXPOSE 80
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed packages specified in pyproject.toml
RUN apt-get update && apt-get install -y gcc curl && \
curl -sSL https://install.python-poetry.org | python - && \
echo "export PATH=$PATH:/root/.local/bin" >> ~/.bashrc && \
. ~/.bashrc && \
poetry config virtualenvs.create false && \
poetry install --no-dev
# Run server.py when the container launches
CMD ["python", "server.py"]

41
main.py Normal file
View File

@ -0,0 +1,41 @@
import os
from importlib import import_module
from os.path import exists
from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL
from starlette.applications import Starlette
from services.schema import resolvers
from services.rediscache import redis
from settings import DEV_SERVER_PID_FILE_NAME, SENTRY_DSN, MODE
import_module("resolvers")
schema = make_executable_schema(load_schema_from_path("inbox.graphql"), resolvers) # type: ignore
async def start_up():
if MODE == "dev":
if exists(DEV_SERVER_PID_FILE_NAME):
await redis.connect()
return
else:
with open(DEV_SERVER_PID_FILE_NAME, "w", encoding="utf-8") as f:
f.write(str(os.getpid()))
else:
await redis.connect()
try:
import sentry_sdk
sentry_sdk.init(SENTRY_DSN)
except Exception as e:
print("[sentry] init error")
print(e)
async def shutdown():
await redis.disconnect()
app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown])
app.mount("/", GraphQL(schema, debug=True))

114
nginx.conf.sigil Normal file
View File

@ -0,0 +1,114 @@
# sigil ver 2.2 dufok 2022-10-15 (with location /connect nobuffer)
# Proxy settings
{{ $proxy_settings := "proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Request-Start $msec;" }}
# GZIP settings
{{ $gzip_settings := "gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6;" }}
# CORS headers based on request methods
{{ $cors_headers_options := "if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; }" }}
{{ $cors_headers_post := "if ($request_method = 'POST') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; }" }}
{{ $cors_headers_get := "if ($request_method = 'GET') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; }" }}
# Mapping for allowed origins
map $http_origin $allow_origin {
~^https?:\/\/((.*\.)?localhost(:\d+)?|discoursio-webapp(-(.*))?\.vercel\.app|(.*\.)?discours\.io)$ $http_origin;
default "";
}
# Server block setup
{{ range $port_map := .PROXY_PORT_MAP | split " " }}
{{ $port_map_list := $port_map | split ":" }}
{{ $scheme := index $port_map_list 0 }}
{{ $listen_port := index $port_map_list 1 }}
{{ $upstream_port := index $port_map_list 2 }}
server {
# HTTP/HTTPS settings
{{ if eq $scheme "http" }}
listen [::]:{{ $listen_port }};
listen {{ $listen_port }};
server_name {{ $.NOSSL_SERVER_NAME }};
access_log /var/log/nginx/{{ $.APP }}-access.log;
error_log /var/log/nginx/{{ $.APP }}-error.log;
{{ else if eq $scheme "https" }}
listen [::]:{{ $listen_port }} ssl http2;
listen {{ $listen_port }} ssl http2;
server_name {{ $.NOSSL_SERVER_NAME }};
access_log /var/log/nginx/{{ $.APP }}-access.log;
error_log /var/log/nginx/{{ $.APP }}-error.log;
ssl_certificate {{ $.APP_SSL_PATH }}/server.crt;
ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
keepalive_timeout 70;
{{ end }}
# Default location block
location / {
proxy_pass http://{{ $.APP }}-{{ $upstream_port }};
{{ $proxy_settings }}
{{ $gzip_settings }}
{{ $cors_headers_options }}
{{ $cors_headers_post }}
{{ $cors_headers_get }}
}
# Custom location block for /connect
location /connect/ {
proxy_pass http://presence-8080/;
add_header 'Cache-Control' 'no-cache';
add_header 'Content-Type' 'text/event-stream';
add_header 'Connection' 'keep-alive';
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 36000s;
{{ $proxy_settings }}
{{ $cors_headers_options }}
{{ $cors_headers_post }}
{{ $cors_headers_get }}
}
# Error pages
error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html;
location /400-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 404 /404-error.html;
location /404-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html;
location /500-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
error_page 502 /502-error.html;
location /502-error.html {
root /var/lib/dokku/data/nginx-vhosts/dokku-errors;
internal;
}
include /home/dokku/gateway/nginx.conf.d/*.conf;
}
{{ end }}
# Upstream setup
{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }}
upstream {{ $.APP }}-{{ $upstream_port }} {
{{ range $listeners := $.DOKKU_APP_WEB_LISTENERS | split " " }}
{{ $listener_list := $listeners | split ":" }}
{{ $listener_ip := index $listener_list 0 }}
{{ $listener_port := index $listener_list 1 }}
server {{ $listener_ip }}:{{ $upstream_port }};
{{ end }}
}
{{ end }}

46
orm/author.py Normal file
View File

@ -0,0 +1,46 @@
import time
from sqlalchemy import JSON as JSONType
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from services.db import Base
class AuthorRating(Base):
__tablename__ = "author_rating"
id = None # type: ignore
rater = Column(ForeignKey("author.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
value = Column(Integer)
class AuthorFollower(Base):
__tablename__ = "author_follower"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
class Author(Base):
__tablename__ = "author"
user = Column(String, unique=True) # unbounded link with authorizer's User type
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="Author's slug")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
pic = Column(String, nullable=True, comment="Picture")
links = Column(JSONType, nullable=True, comment="Links")
ratings = relationship(AuthorRating, foreign_keys=AuthorRating.author)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
last_seen = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True, comment="Deleted at")

25
orm/collection.py Normal file
View File

@ -0,0 +1,25 @@
import time
from sqlalchemy import Column, ForeignKey, Integer, String
from services.db import Base
class ShoutCollection(Base):
__tablename__ = "shout_collection"
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True)
class Collection(Base):
__tablename__ = "collection"
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By")
publishedAt = Column(Integer, default=lambda: int(time.time()))

41
orm/community.py Normal file
View File

@ -0,0 +1,41 @@
import time
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from services.db import Base, local_session
class CommunityAuthor(Base):
__tablename__ = "community_author"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True)
community = Column(ForeignKey("community.id"), primary_key=True)
joined_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
role = Column(String, nullable=False)
class Community(Base):
__tablename__ = "community"
name = Column(String, nullable=False)
slug = Column(String, nullable=False, unique=True)
desc = Column(String, nullable=False, default="")
pic = Column(String, nullable=False, default="")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
authors = relationship(lambda: Author, secondary=CommunityAuthor.__tablename__)
@staticmethod
def init_table():
with local_session("orm.community") as session:
d = session.query(Community).filter(Community.slug == "discours").first()
if not d:
d = Community(name="Дискурс", slug="discours")
session.add(d)
session.commit()
print("[orm.community] created community %s" % d.slug)
Community.default_community = d
print("[orm.community] default community is %s" % d.slug)

32
orm/notification.py Normal file
View File

@ -0,0 +1,32 @@
from enum import Enum as Enumeration
import time
from sqlalchemy import Boolean, Column, Enum, Integer
from sqlalchemy.dialects.postgresql import JSONB
from services.db import Base
class NotificationEntity(Enumeration):
REACTION = 1
SHOUT = 2
FOLLOWER = 3
class NotificationAction(Enumeration):
CREATE = 1
UPDATE = 2
DELETE = 3
SEEN = 4
FOLLOW = 5
UNFOLLOW = 6
class Notification(Base):
__tablename__ = "notification"
created_at = Column(Integer, default=lambda: int(time.time()))
seen = Column(Boolean, nullable=False, default=False, index=True)
entity = Column(Enum(NotificationEntity), nullable=False)
action = Column(Enum(NotificationAction), nullable=False)
payload = Column(JSONB, nullable=True)
# occurrences = Column(Integer, default=1)

41
orm/reaction.py Normal file
View File

@ -0,0 +1,41 @@
import time
from enum import Enum as Enumeration
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from services.db import Base
class ReactionKind(Enumeration):
AGREE = 1 # +1
DISAGREE = 2 # -1
PROOF = 3 # +1
DISPROOF = 4 # -1
ASK = 5 # +0
PROPOSE = 6 # +0
QUOTE = 7 # +0 bookmark
COMMENT = 8 # +0
ACCEPT = 9 # +1
REJECT = 0 # -1
LIKE = 11 # +1
DISLIKE = 12 # -1
REMARK = 13 # 0
FOOTNOTE = 14 # 0
# TYPE = <reaction index> # rating diff
class Reaction(Base):
__tablename__ = "reaction"
body = Column(String, nullable=True, comment="Reaction Body")
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), nullable=False, index=True)
updated_at = Column(Integer, nullable=True, comment="Updated at")
deleted_at = Column(Integer, nullable=True, comment="Deleted at")
deleted_by = Column(ForeignKey("author.id"), nullable=True, index=True)
shout = Column(ForeignKey("shout.id"), nullable=False, index=True)
reply_to = Column(ForeignKey("reaction.id"), nullable=True)
range = Column(String, nullable=True, comment="<start index>:<end>")
kind = Column(Enum(ReactionKind), nullable=False)
oid = Column(String)

86
orm/shout.py Normal file
View File

@ -0,0 +1,86 @@
import time
from enum import Enum as Enumeration
from sqlalchemy import JSON, Boolean, Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from orm.author import Author
from orm.community import Community
from orm.reaction import Reaction
from orm.topic import Topic
from services.db import Base
class ShoutTopic(Base):
__tablename__ = "shout_topic"
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
class ShoutReactionsFollower(Base):
__tablename__ = "shout_reactions_followers"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
auto = Column(Boolean, nullable=False, default=False)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
deleted_at = Column(Integer, nullable=True)
class ShoutAuthor(Base):
__tablename__ = "shout_author"
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
caption = Column(String, nullable=True, default="")
class ShoutCommunity(Base):
__tablename__ = "shout_community"
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True, index=True)
community = Column(ForeignKey("community.id"), primary_key=True, index=True)
class ShoutVisibility(Enumeration):
AUTHORS = 0
COMMUNITY = 1
PUBLIC = 2
class Shout(Base):
__tablename__ = "shout"
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at = Column(Integer, nullable=True)
published_at = Column(Integer, nullable=True)
deleted_at = Column(Integer, nullable=True)
deleted_by = Column(ForeignKey("author.id"), nullable=True)
body = Column(String, nullable=False, comment="Body")
slug = Column(String, unique=True)
cover = Column(String, nullable=True, comment="Cover image url")
lead = Column(String, nullable=True)
description = Column(String, nullable=True)
title = Column(String, nullable=True)
subtitle = Column(String, nullable=True)
layout = Column(String, nullable=True)
media = Column(JSON, nullable=True)
authors = relationship(lambda: Author, secondary="shout_author")
topics = relationship(lambda: Topic, secondary="shout_topic")
communities = relationship(lambda: Community, secondary="shout_community")
reactions = relationship(lambda: Reaction)
visibility = Column(Enum(ShoutVisibility), default=ShoutVisibility.AUTHORS)
lang = Column(String, nullable=False, default="ru", comment="Language")
version_of = Column(ForeignKey("shout.id"), nullable=True)
oid = Column(String, nullable=True)

26
orm/topic.py Normal file
View File

@ -0,0 +1,26 @@
import time
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from services.db import Base
class TopicFollower(Base):
__tablename__ = "topic_followers"
id = None # type: ignore
follower = Column(ForeignKey("author.id"), primary_key=True, index=True)
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
auto = Column(Boolean, nullable=False, default=False)
class Topic(Base):
__tablename__ = "topic"
slug = Column(String, unique=True)
title = Column(String, nullable=False, comment="Title")
body = Column(String, nullable=True, comment="Body")
pic = Column(String, nullable=True, comment="Picture")
community = Column(ForeignKey("community.id"), default=1)
oid = Column(String, nullable=True, comment="Old ID")

30
orm/user.py Normal file
View File

@ -0,0 +1,30 @@
import time
from sqlalchemy import JSON, Boolean, Column, Integer, String
from services.db import Base
class User(Base):
__tablename__ = "authorizer_users"
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
key = Column(String)
email = Column(String, unique=True)
email_verified_at = Column(Integer)
family_name = Column(String)
gender = Column(String)
given_name = Column(String)
is_multi_factor_auth_enabled = Column(Boolean)
middle_name = Column(String)
nickname = Column(String)
password = Column(String)
phone_number = Column(String, unique=True)
phone_number_verified_at = Column(Integer)
# preferred_username = Column(String, nullable=False)
picture = Column(String)
revoked_timestamp = Column(Integer)
roles = Column(JSON)
signup_methods = Column(String, default="magic_link_login")
created_at = Column(Integer, default=lambda: int(time.time()))
updated_at = Column(Integer, default=lambda: int(time.time()))

67
pyproject.toml Normal file
View File

@ -0,0 +1,67 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "discoursio-notifier"
version = "0.0.1"
description = "notifier server for discours.io"
authors = ["discours.io devteam"]
[tool.poetry.dependencies]
python = "^3.12"
SQLAlchemy = "^2.0.22"
httpx = "^0.25.0"
psycopg2-binary = "^2.9.9"
redis = {extras = ["hiredis"], version = "^5.0.1"}
sentry-sdk = "^1.32.0"
gql = {git = "https://github.com/graphql-python/gql.git", rev = "master"}
ariadne = {git = "https://github.com/tonyrewin/ariadne.git", rev = "master"}
starlette = {git = "https://github.com/encode/starlette.git", rev = "master"}
[tool.poetry.dev-dependencies]
pytest = "^7.4.2"
black = { version = "^23.9.1", python = ">=3.12" }
ruff = { version = "^0.1.0", python = ">=3.12" }
[tool.black]
line-length = 120
target-version = ['py312']
include = '\.pyi?$'
exclude = '''
(
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
| foo.py # also separately exclude a file named foo.py in
# the root of the project
)
'''
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
line_length = 120
[tool.ruff]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F"]
ignore = []
line-length = 120
target-version = "py312"

View File

@ -0,0 +1,84 @@
from sqlalchemy import and_, desc, select, update
from services.auth import login_required
from services.db import local_session
from services.schema import mutation, query
from orm.notification import Notification
@query.field("loadNotifications")
@login_required
async def load_notifications(_, info, params=None):
if params is None:
params = {}
user_id = info.context["user_id"]
limit = params.get("limit", 50)
offset = params.get("offset", 0)
q = (
select(Notification)
.order_by(desc(Notification.created_at))
.limit(limit)
.offset(offset)
)
notifications = []
with local_session() as session:
total_count = session.query(Notification).where(Notification.author == author_id).count()
total_unread_count = (
session.query(Notification)
.where(and_(Notification.author == author_id, Notification.seen == False)) # noqa: E712
.count()
)
for [notification] in session.execute(q):
notification.type = notification.type.name
notifications.append(notification)
return {
"notifications": notifications,
"totalCount": total_count,
"totalUnreadCount": total_unread_count,
}
@mutation.field("markNotificationAsRead")
@login_required
async def mark_notification_as_read(_, info, notification_id: int):
author_id = info.context["author_id"]
with local_session() as session:
notification = (
session.query(Notification)
.where(and_(Notification.id == notification_id, Notification.user == user_id))
.one()
)
notification.seen = True
session.commit()
return {}
@mutation.field("markAllNotificationsAsRead")
@login_required
async def mark_all_notifications_as_read(_, info):
auth: AuthCredentials = info.context["request"].auth
user_id = auth.user_id
statement = (
update(Notification)
.where(and_(Notification.user == user_id, Notification.seen == False)) # noqa: E712
.values(seen=True)
)
with local_session() as session:
try:
session.execute(statement)
session.commit()
except Exception as e:
session.rollback()
print(f"[mark_all_notifications_as_read] error: {str(e)}")
return {}

59
server.py Normal file
View File

@ -0,0 +1,59 @@
import sys
import uvicorn
from uvicorn.main import logger
from settings import PORT
log_settings = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO"},
"uvicorn.error": {"level": "INFO", "handlers": ["default"], "propagate": True},
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}
local_headers = [
("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD"),
("Access-Control-Allow-Origin", "https://localhost:3000"),
(
"Access-Control-Allow-Headers",
"DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization",
),
("Access-Control-Expose-Headers", "Content-Length,Content-Range"),
("Access-Control-Allow-Credentials", "true"),
]
def exception_handler(_et, exc, _tb):
logger.error(..., exc_info=(type(exc), exc, exc.__traceback__))
if __name__ == "__main__":
sys.excepthook = exception_handler
uvicorn.run("main:app", host="0.0.0.0", port=PORT, proxy_headers=True, server_header=True)

64
services/auth.py Normal file
View File

@ -0,0 +1,64 @@
from functools import wraps
from httpx import AsyncClient, HTTPError
from settings import AUTH_URL
async def check_auth(req):
token = req.headers.get("Authorization")
headers = {"Authorization": token, "Content-Type": "application/json"} # "Bearer " + removed
print(f"[services.auth] checking auth token: {token}")
query_name = "getSession" if "v2." in AUTH_URL else "session"
query_type = "mutation" if "v2." in AUTH_URL else "query"
operation = "GetUserId"
gql = {
"query": query_type + " " + operation + " { " + query_name + " { user { id } } " + " }",
"operationName": operation,
"variables": None,
}
async with AsyncClient(timeout=30.0) as client:
response = await client.post(AUTH_URL, headers=headers, json=gql)
print(f"[services.auth] {AUTH_URL} response: {response.status_code}")
if response.status_code != 200:
return False, None
r = response.json()
if r:
user_id = r.get("data", {}).get(query_name, {}).get("user", {}).get("id", None)
is_authenticated = user_id is not None
return is_authenticated, user_id
return False, None
def login_required(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
info = args[1]
context = info.context
req = context.get("request")
is_authenticated, user_id = await check_auth(req)
if not is_authenticated:
raise Exception("You are not logged in")
else:
# Добавляем author_id в контекст
context["user_id"] = user_id
# Если пользователь аутентифицирован, выполняем резолвер
return await f(*args, **kwargs)
return decorated_function
def auth_request(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
req = args[0]
is_authenticated, user_id = await check_auth(req)
if not is_authenticated:
raise HTTPError("please, login first")
else:
req["author_id"] = user_id
return await f(*args, **kwargs)
return decorated_function

62
services/core.py Normal file
View File

@ -0,0 +1,62 @@
from httpx import AsyncClient
from settings import API_BASE
from typing import List
from models.member import ChatMember
headers = {"Content-Type": "application/json"}
async def get_all_authors() -> List[ChatMember]:
query_name = "authorsAll"
query_type = "query"
operation = "AuthorsAll"
query_fields = "id slug userpic name"
gql = {
"query": query_type + " " + operation + " { " + query_name + " { " + query_fields + " } " + " }",
"operationName": operation,
"variables": None,
}
async with AsyncClient() as client:
try:
response = await client.post(API_BASE, headers=headers, json=gql)
print(f"[services.core] {query_name}: [{response.status_code}] {len(response.text)} bytes")
if response.status_code != 200:
return []
r = response.json()
if r:
return r.get("data", {}).get(query_name, [])
except Exception:
import traceback
traceback.print_exc()
return []
async def get_my_followings() -> List[ChatMember]:
query_name = "loadMySubscriptions"
query_type = "query"
operation = "LoadMySubscriptions"
query_fields = "id slug userpic name"
gql = {
"query": query_type + " " + operation + " { " + query_name + " { authors {" + query_fields + "} } " + " }",
"operationName": operation,
"variables": None,
}
async with AsyncClient() as client:
try:
response = await client.post(API_BASE, headers=headers, json=gql)
print(f"[services.core] {query_name}: [{response.status_code}] {len(response.text)} bytes")
if response.status_code != 200:
return []
r = response.json()
if r:
return r.get("data", {}).get(query_name, {}).get("authors", [])
except Exception:
import traceback
traceback.print_exc()
return []

67
services/db.py Normal file
View File

@ -0,0 +1,67 @@
# from contextlib import contextmanager
from typing import Any, Callable, Dict, TypeVar
# from psycopg2.errors import UniqueViolation
from sqlalchemy import Column, Integer, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session
from sqlalchemy.sql.schema import Table
from settings import DB_URL
engine = create_engine(DB_URL, echo=False, pool_size=10, max_overflow=20)
T = TypeVar("T")
REGISTRY: Dict[str, type] = {}
# @contextmanager
def local_session(src=""):
return Session(bind=engine, expire_on_commit=False)
# try:
# yield session
# session.commit()
# except Exception as e:
# if not (src == "create_shout" and isinstance(e, UniqueViolation)):
# import traceback
# session.rollback()
# print(f"[services.db] {src}: {e}")
# traceback.print_exc()
# raise Exception("[services.db] exception")
# finally:
# session.close()
class Base(declarative_base()):
__table__: Table
__tablename__: str
__new__: Callable
__init__: Callable
__allow_unmapped__ = True
__abstract__ = True
__table_args__ = {"extend_existing": True}
id = Column(Integer, primary_key=True)
def __init_subclass__(cls, **kwargs):
REGISTRY[cls.__name__] = cls
def dict(self) -> Dict[str, Any]:
column_names = self.__table__.columns.keys()
if "_sa_instance_state" in column_names:
column_names.remove("_sa_instance_state")
try:
return {c: getattr(self, c) for c in column_names}
except Exception as e:
print(f"[services.db] Error dict: {e}")
return {}
def update(self, values: Dict[str, Any]) -> None:
for key, value in values.items():
if hasattr(self, key):
setattr(self, key, value)

61
services/rediscache.py Normal file
View File

@ -0,0 +1,61 @@
import redis.asyncio as aredis
from settings import REDIS_URL
class RedisCache:
def __init__(self, uri=REDIS_URL):
self._uri: str = uri
self.pubsub_channels = []
self._client = None
async def connect(self):
self._client = aredis.Redis.from_url(self._uri, decode_responses=True)
async def disconnect(self):
if self._client:
await self._client.close()
async def execute(self, command, *args, **kwargs):
if self._client:
try:
print("[redis] " + command + " " + " ".join(args))
r = await self._client.execute_command(command, *args, **kwargs)
return r
except Exception as e:
print(f"[redis] error: {e}")
return None
async def subscribe(self, *channels):
if self._client:
async with self._client.pubsub() as pubsub:
for channel in channels:
await pubsub.subscribe(channel)
self.pubsub_channels.append(channel)
async def unsubscribe(self, *channels):
if not self._client:
return
async with self._client.pubsub() as pubsub:
for channel in channels:
await pubsub.unsubscribe(channel)
self.pubsub_channels.remove(channel)
async def publish(self, channel, data):
if not self._client:
return
await self._client.publish(channel, data)
async def lrange(self, key, start, stop):
if self._client:
print(f"[redis] LRANGE {key} {start} {stop}")
return await self._client.lrange(key, start, stop)
async def mget(self, key, *keys):
if self._client:
print(f"[redis] MGET {key} {keys}")
return await self._client.mget(key, *keys)
redis = RedisCache()
__all__ = ["redis"]

5
services/schema.py Normal file
View File

@ -0,0 +1,5 @@
from ariadne import QueryType, MutationType
query = QueryType()
mutation = MutationType()
resolvers = [query, mutation]

9
settings.py Normal file
View File

@ -0,0 +1,9 @@
from os import environ
PORT = 80
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
API_BASE = environ.get("API_BASE") or ""
AUTH_URL = environ.get("AUTH_URL") or ""
MODE = environ.get("MODE") or "production"
SENTRY_DSN = environ.get("SENTRY_DSN")
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"