diff --git a/cloudlink.py b/python/cloudlink.py similarity index 100% rename from cloudlink.py rename to python/cloudlink.py diff --git a/database.py b/python/database.py similarity index 100% rename from database.py rename to python/database.py diff --git a/python/events.py b/python/events.py new file mode 100644 index 0000000..1a0fb5f --- /dev/null +++ b/python/events.py @@ -0,0 +1,150 @@ +""" +This module connects the API to the websocket server. +""" + +from typing import Any + +import msgpack + +from database import rdb, db +from supporter import Supporter + +OpCreateUser = 0 +OpUpdateUser = 1 +OpDeleteUser = 2 +OpUpdateUserSettings = 3 + +OpRevokeSession = 4 + +OpUpdateRelationship = 5 + +OpCreateChat = 6 +OpUpdateChat = 7 +OpDeleteChat = 8 + +OpCreateChatMember = 9 +OpUpdateChatMember = 10 +OpDeleteChatMember = 11 + +OpCreateChatEmote = 12 +OpUpdateChatEmote = 13 +OpDeleteChatEmote = 14 + +OpTyping = 15 + +OpCreatePost = 16 +OpUpdatePost = 17 +OpDeletePost = 18 +OpBulkDeletePosts = 19 + +OpPostReactionAdd = 20 +OpPostReactionRemove = 21 + +class Events: + def __init__(self): + # noinspection PyTypeChecker + self.supporter: Supporter = None + + def add_supporter(self, supporter: Supporter): + self.supporter = supporter + + def parse_post_meowid(self, post: dict[str, Any], include_replies: bool = True): + post = list(self.supporter.parse_posts_v0([post], include_replies=include_replies, include_revisions=False))[0] + + match post["post_origin"]: + case "home": + chat_id = 0 + case "livechat": + chat_id = 1 + case "inbox": + chat_id = 2 + case _: + chat_id = db.get_collection("chats").find_one({"_id": post["post_origin"]}, projection={"meowid": 1})[ + "meowid"] + + replys = [] + if include_replies: + replys = [reply["meowid"] for reply in post["reply_to"]] + + return { + "id": post["meowid"], + "chat_id": chat_id, + "author_id": post["author"]["meowid"], + "reply_to_ids": replys, + "emoji_ids": [emoji["id"] for emoji in post["emojis"]], + "sticker_ids": post["stickers"], + "attachments": post["attachments"], + "content": post["p"], + "reactions": [{ + "emoji": reaction["emoji"], + "count": reaction["count"] + } for reaction in post["reactions"]], + "last_edited": post.get("edited_at", 0), + "pinned": post["pinned"] + } + + @staticmethod + def parse_user_meowid(partial_user: dict[str, Any]): + quote = db.get_collection("usersv0").find_one({"_id": partial_user["_id"]}, projection={"quote": 1})["quote"] + return { + "id": partial_user["meowid"], + "username": partial_user["_id"], + "flags": partial_user["flags"], + "avatar": partial_user["avatar"], + "legacy_avatar": partial_user["pfp_data"], + "color": partial_user["avatar_color"], + "quote": quote + } + + def send_post_event(self, original_post: dict[str, Any]): + post = self.parse_post_meowid(original_post, include_replies=True) + + users = [self.parse_user_meowid(post["author"])] + + replies = {} + for reply in post["reply_to_ids"]: + replies[reply] = self.parse_post_meowid(db.get_collection("posts").find_one({"meowid": reply}), + include_replies=False) + users.append(self.parse_user_meowid(replies[reply]["author"])) + + emotes = {} + for emoji in post["emoji_ids"]: + emotes[emoji["_id"]] = { + "id": emoji["_id"], + "chat_id": db.get_collection("chats").find_one({"_id": emoji["chat_id"]}, projection={"meowid": 1})[ + "meowid"], + "name": emoji["name"], + "animated": emoji["animated"], + } + + data = { + "post": post, + "reply_to": replies, + "emotes": emotes, + "attachments": original_post["attachments"], + "author": users, + } + + is_dm = db.get_collection("chats").find_one({"_id": original_post["post_origin"], "owner": None}, + projection={"meowid": 1}) + if is_dm: + data["dm_to"] = db.get_collection("users") \ + .find_one({"_id": original_post["author"]["_id"]}, projection={"meowid": 1}) \ + ["meowid"] + + data["dm_chat"] = None # unspecifed + + if "nonce" in original_post: + data["nonce"] = original_post["nonce"] + + self.send_event(OpCreatePost, data) + + @staticmethod + def send_event(event: int, data: dict[str, any]): + payload = bytearray(msgpack.packb(data)) + payload.insert(0, event) + + rdb.publish("events", payload) + + +events = Events() diff --git a/grpc_auth/auth_service_pb2.py b/python/grpc_auth/auth_service_pb2.py similarity index 100% rename from grpc_auth/auth_service_pb2.py rename to python/grpc_auth/auth_service_pb2.py diff --git a/grpc_auth/auth_service_pb2.pyi b/python/grpc_auth/auth_service_pb2.pyi similarity index 100% rename from grpc_auth/auth_service_pb2.pyi rename to python/grpc_auth/auth_service_pb2.pyi diff --git a/grpc_auth/auth_service_pb2_grpc.py b/python/grpc_auth/auth_service_pb2_grpc.py similarity index 100% rename from grpc_auth/auth_service_pb2_grpc.py rename to python/grpc_auth/auth_service_pb2_grpc.py diff --git a/grpc_auth/service.py b/python/grpc_auth/service.py similarity index 100% rename from grpc_auth/service.py rename to python/grpc_auth/service.py diff --git a/grpc_uploads/client.py b/python/grpc_uploads/client.py similarity index 100% rename from grpc_uploads/client.py rename to python/grpc_uploads/client.py diff --git a/grpc_uploads/uploads_service_pb2.py b/python/grpc_uploads/uploads_service_pb2.py similarity index 100% rename from grpc_uploads/uploads_service_pb2.py rename to python/grpc_uploads/uploads_service_pb2.py diff --git a/grpc_uploads/uploads_service_pb2.pyi b/python/grpc_uploads/uploads_service_pb2.pyi similarity index 100% rename from grpc_uploads/uploads_service_pb2.pyi rename to python/grpc_uploads/uploads_service_pb2.pyi diff --git a/grpc_uploads/uploads_service_pb2_grpc.py b/python/grpc_uploads/uploads_service_pb2_grpc.py similarity index 100% rename from grpc_uploads/uploads_service_pb2_grpc.py rename to python/grpc_uploads/uploads_service_pb2_grpc.py diff --git a/main.py b/python/main.py similarity index 90% rename from main.py rename to python/main.py index b453bb1..87cc33f 100644 --- a/main.py +++ b/python/main.py @@ -1,5 +1,7 @@ # Load .env file from dotenv import load_dotenv + + load_dotenv() import asyncio @@ -13,6 +15,7 @@ from security import background_tasks_loop from grpc_auth import service as grpc_auth from rest_api import app as rest_api +from events import events if __name__ == "__main__": @@ -23,6 +26,8 @@ supporter = Supporter(cl) cl.supporter = supporter + events.add_supporter(supporter) + # Start background tasks loop Thread(target=background_tasks_loop, daemon=True).start() diff --git a/meowid.py b/python/meowid.py similarity index 100% rename from meowid.py rename to python/meowid.py diff --git a/rest_api/__init__.py b/python/rest_api/__init__.py similarity index 100% rename from rest_api/__init__.py rename to python/rest_api/__init__.py diff --git a/rest_api/admin.py b/python/rest_api/admin.py similarity index 100% rename from rest_api/admin.py rename to python/rest_api/admin.py diff --git a/rest_api/v0/__init__.py b/python/rest_api/v0/__init__.py similarity index 100% rename from rest_api/v0/__init__.py rename to python/rest_api/v0/__init__.py diff --git a/rest_api/v0/auth.py b/python/rest_api/v0/auth.py similarity index 100% rename from rest_api/v0/auth.py rename to python/rest_api/v0/auth.py diff --git a/python/rest_api/v0/bots.py b/python/rest_api/v0/bots.py new file mode 100644 index 0000000..f14457b --- /dev/null +++ b/python/rest_api/v0/bots.py @@ -0,0 +1,121 @@ +import os +import uuid +from typing import TYPE_CHECKING + +import requests +from quart import Blueprint, current_app as app, request, abort +from quart_schema import validate_request +from pydantic import BaseModel + +import security +import secrets + +from database import db + +if TYPE_CHECKING: + class Reqest: + user: str + flags: int + permissions: int + + request: Reqest + + from cloudlink import CloudlinkServer + from supporter import Supporter + class App: + supporter: "Supporter" + cl: "CloudlinkServer" + + + app: App + +bots_bp = Blueprint("bots", __name__, url_prefix="/bots") + +class CreateBot(BaseModel): + name: str + description: str + captcha: str + +class SecureRequests(BaseModel): + mfa_code: int + +@bots_bp.post("/") +@validate_request(CreateBot) +async def create_bot(data: CreateBot): + if not request.user: + abort(401) + + if os.getenv("CAPTCHA_SECRET") and not (hasattr(request, "bypass_captcha") and request.bypass_captcha): + if not requests.post("https://api.hcaptcha.com/siteverify", data={ + "secret": os.getenv("CAPTCHA_SECRET"), + "response": data.captcha, + }).json()["success"]: + return {"error": True, "type": "invalidCaptcha"}, 403 + + if not (security.ratelimited(f"bot:create:{request.user}")): + abort(429) + + + if any([ + db.users.find_one({"lower_username": data.name.lower()}), + db.bots.find_one({"name": data.name}) + ]): + return {"error": True, "type": "nameTaken"}, 409 + + token = secrets.token_urlsafe(32) + + bot = { + "_id": str(uuid.uuid4()), + "token": security.hash_password(token), + "name": data.name, + "description": data.description, + "owner": request.user, + "avatar": { + "default": 1, + "custom": None + } + } + + db.bots.insert_one(bot) + security.ratelimit(f"bot:create:{request.user}", 1, 60) + + bot["token"] = token + bot["error"] = False + + return bot, 200 + + +@bots_bp.get("/") +async def get_bots(): + if not request.user: + abort(401) + + bots = list(db.bots.find({"owner": request.user}, projection={"_id": 1, "name": 1, "avatar": 1})) + return {"error": False, "bots": bots}, 200 + +@bots_bp.get("/") +async def get_bot(bot_id: str): + if not request.user: + abort(401) + + bot = db.bots.find_one({"_id": bot_id, "owner": request.user}, projection={"token": 0}) + if not bot: + abort(404) + + return {"error": False, "bot": bot}, 200 + +@bots_bp.delete("/") +@validate_request(SecureRequests) +async def delete_bot(bot_id: str, data: SecureRequests): + if not request.user: + abort(401) + + bot = db.bots.find_one({"_id": bot_id, "owner": request.user}) + if not bot: + abort(404) + + if not security.check_mfa(request.user, data.mfa_code): + return {"error": True, "type": "invalidMfa"}, 403 + + db.bots.delete_one({"_id": bot_id}) + return {"error": False}, 200 \ No newline at end of file diff --git a/rest_api/v0/chats.py b/python/rest_api/v0/chats.py similarity index 100% rename from rest_api/v0/chats.py rename to python/rest_api/v0/chats.py diff --git a/rest_api/v0/home.py b/python/rest_api/v0/home.py similarity index 100% rename from rest_api/v0/home.py rename to python/rest_api/v0/home.py diff --git a/rest_api/v0/inbox.py b/python/rest_api/v0/inbox.py similarity index 100% rename from rest_api/v0/inbox.py rename to python/rest_api/v0/inbox.py diff --git a/rest_api/v0/me.py b/python/rest_api/v0/me.py similarity index 100% rename from rest_api/v0/me.py rename to python/rest_api/v0/me.py diff --git a/rest_api/v0/posts.py b/python/rest_api/v0/posts.py similarity index 100% rename from rest_api/v0/posts.py rename to python/rest_api/v0/posts.py diff --git a/rest_api/v0/search.py b/python/rest_api/v0/search.py similarity index 100% rename from rest_api/v0/search.py rename to python/rest_api/v0/search.py diff --git a/rest_api/v0/users.py b/python/rest_api/v0/users.py similarity index 100% rename from rest_api/v0/users.py rename to python/rest_api/v0/users.py diff --git a/security.py b/python/security.py similarity index 100% rename from security.py rename to python/security.py diff --git a/supporter.py b/python/supporter.py similarity index 70% rename from supporter.py rename to python/supporter.py index 44bc066..e8eb36c 100644 --- a/supporter.py +++ b/python/supporter.py @@ -16,36 +16,6 @@ FILE_ID_REGEX = "[a-zA-Z0-9]{24}" CUSTOM_EMOJI_REGEX = f"<:({FILE_ID_REGEX})>" -OpCreateUser = 0 -OpUpdateUser = 1 -OpDeleteUser = 2 -OpUpdateUserSettings = 3 - -OpRevokeSession = 4 - -OpUpdateRelationship = 5 - -OpCreateChat = 6 -OpUpdateChat = 7 -OpDeleteChat = 8 - -OpCreateChatMember = 9 -OpUpdateChatMember = 10 -OpDeleteChatMember = 11 - -OpCreateChatEmote = 12 -OpUpdateChatEmote = 13 -OpDeleteChatEmote = 14 - -OpTyping = 15 - -OpCreatePost = 16 -OpUpdatePost = 17 -OpDeletePost = 18 -OpBulkDeletePosts = 19 - -OpPostReactionAdd = 20 -OpPostReactionRemove = 21 class Supporter: @@ -279,79 +249,3 @@ def parse_posts_v0( return posts - @staticmethod - def send_event(event: int, data: dict[str, any]): - payload = bytearray(msgpack.packb(data)) - payload.insert(0, event) - - rdb.publish("events", payload) - - - def parse_post_meowid(self, post: dict[str, Any], include_replies: bool = True): - post = list(self.parse_posts_v0([post], include_replies=include_replies, include_revisions=False))[0] - - match post["post_origin"]: - case "home": - chat_id = 0 - case "livechat": - chat_id = 1 - case "inbox": - chat_id = 2 - case _: - chat_id = db.get_collection("chats").find_one({"_id": post["post_origin"]}, projection={"meowid": 1})["meowid"] - - replys = [] - if include_replies: - replys = [reply["meowid"] for reply in post["reply_to"]] - - return { - "id": post["meowid"], - "chat_id": chat_id, - "author_id": post["author"]["meowid"], - "reply_to_ids": replys, - "emoji_ids": [emoji["id"] for emoji in post["emojis"]], - "sticker_ids": post["stickers"], - "attachments": post["attachments"], - "content": post["p"], - "reactions": [{ - "emoji": reaction["emoji"], - "count": reaction["count"] - } for reaction in post["reactions"]], - "last_edited": post.get("edited_at", 0), - "pinned": post["pinned"] - } - - def send_post_event(self, original_post: dict[str, Any]): - post = self.parse_post_meowid(original_post, include_replies=True) - - replies = {} - for reply in post["reply_to_ids"]: - replies[reply] = self.parse_post_meowid(db.get_collection("posts").find_one({"meowid": reply}), include_replies=False) - - # TODO: What is the users field? - - emotes = {} - for emoji in post["emoji_ids"]: - emotes[emoji["_id"]] = { - "id": emoji["_id"], - "chat_id": db.get_collection("chats").find_one({"_id": emoji["chat_id"]}, projection={"meowid": 1})["meowid"], - "name": emoji["name"], - "animated": emoji["animated"], - } - - data = { - "post": post, - "reply_to": replies, - "emotes": emotes, - "attachments": original_post["attachments"], - } - - is_dm = db.get_collection("chats").find_one({"_id": original_post["post_origin"], "owner": None}, projection={"meowid": 1}) - if is_dm: - data["dm_to"] = db.get_collection("users").find_one({"_id": original_post["author"]["_id"]}, projection={"meowid": 1})["meowid"] - data["dm_chat"] = None # unspecifed - - if "nonce" in original_post: - data["nonce"] = original_post["nonce"] - - self.send_event(OpCreatePost, data) diff --git a/uploads.py b/python/uploads.py similarity index 100% rename from uploads.py rename to python/uploads.py diff --git a/utils.py b/python/utils.py similarity index 100% rename from utils.py rename to python/utils.py