commit 94a278dbd440a0be780dda51d4165a8518b7fba0 Author: MathiasDPX Date: Thu Apr 17 15:07:44 2025 +0200 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62c035d --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +db_name="" +db_user="" +db_password="" +db_host=postgres.hackclub.app +db_port=5432 + +SYNC_SERVER_URL="sync.example.com" +HOST="0.0.0.0" +PORT=80 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa2837 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +syncfile.md +doc.py + +index.json +data/* +.env + +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d3d959 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Obsidian FreeSync +Clone of the official [Obsidian Sync](https://obsidian.md/sync) backend for having Obsidian for free. + +Currently not secured and should don't support end-to-end encryption \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..acecd41 --- /dev/null +++ b/database.py @@ -0,0 +1,272 @@ +import os +import time +import logging +import psycopg2 +from os import getenv +from glob import glob +from dotenv import load_dotenv +from typing import Optional, Any +from psycopg2 import OperationalError + +load_dotenv() + +class DatabaseConnector: + def __init__( + self, + dbname: str, + user: str, + password: str, + host: str = "localhost", + port: int = 5432, + max_retries: int = 3, + retry_delay: int = 5 + ): + """ + Initialize database connector with connection parameters and retry settings. + + Args: + dbname: Database name + user: Database user + password: Database password + host: Database host + port: Database port + max_retries: Maximum number of reconnection attempts + retry_delay: Delay between retry attempts in seconds + """ + self.conn_params = { + "dbname": dbname, + "user": user, + "password": password, + "host": host, + "port": port + } + self.max_retries = max_retries + self.retry_delay = retry_delay + self.conn: Optional[psycopg2.extensions.connection] = None + self.cur: Optional[psycopg2.extensions.cursor] = None + + # Set up logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + self.logger = logging.getLogger(__name__) + + def connect(self) -> bool: + """ + Establish database connection. + + Returns: + bool: True if connection successful, False otherwise + """ + try: + self.conn = psycopg2.connect(**self.conn_params) + self.cur = self.conn.cursor() + self.logger.info("Successfully connected to the database") + return True + except OperationalError as e: + self.logger.error(f"Error connecting to the database: {e}") + return False + + def ensure_connection(self) -> bool: + """ + Ensure database connection is active, attempt to reconnect if necessary. + + Returns: + bool: True if connection is active or reconnection successful + """ + if self.conn and not self.conn.closed: + try: + # Test connection with simple query + self.cur.execute("SELECT 1") + return True + except (psycopg2.Error, AttributeError): + self.logger.warning("Database connection lost") + + # Connection is closed or failed, attempt to reconnect + for attempt in range(self.max_retries): + self.logger.info(f"Attempting to reconnect (attempt {attempt + 1}/{self.max_retries})") + if self.connect(): + return True + time.sleep(self.retry_delay) + + self.logger.error("Failed to reconnect to database after multiple attempts") + return False + + def execute_query(self, query: str, params: tuple = None) -> Optional[Any]: + """ + Execute a database query with automatic reconnection on failure. + + Args: + query: SQL query string + params: Query parameters (optional) + + Returns: + Query results if successful, None if failed + """ + if not self.ensure_connection(): + return None + + try: + self.cur.execute(query, params) + + # Check if query is a SELECT statement + if query.strip().upper().startswith("SELECT"): + results = self.cur.fetchall() + self.conn.commit() + return results + else: + self.conn.commit() + return True + + except psycopg2.Error as e: + self.logger.error(f"Error executing query: {e}") + self.conn.rollback() + return None + + def close(self): + """Close database connection and cursor.""" + if self.cur: + self.cur.close() + if self.conn: + self.conn.close() + self.logger.info("Database connection closed") + +db = DatabaseConnector( + dbname=getenv("db_name"), + user=getenv("db_user"), + password=getenv("db_password"), + host=getenv("db_host"), + port=getenv("db_port") +) + +def register(email:str, password:str, name:str="unnamed"): + db.execute_query("INSERT INTO users (email,password,name) VALUES (%s,%s,%s)", (email,password,name,)) + +def get_userid_by_token(token:str): + result = db.execute_query("SELECT uid FROM users WHERE token=%s LIMIT 1", (token,)) + return result[0] + +def login(email, password): + result = db.execute_query("SELECT name,token FROM users WHERE email=%s AND password=%s LIMIT 1", (email,password,)) + + if len(result) == 0: + return {"error": "Invalid email or password"} + + return { + "email": email, + "license": "", + "name": result[0][0], + "token": result[0][1] + } + +def get_userinfo(token:str): + result = db.execute_query("SELECT uid,name,email FROM users WHERE token=%s LIMIT 1", (token,)) + if len(result) == 0: + return {"error": "Not logged in"} + + userdata = result[0] + + return { + "credit": 0, + "credit_received": 0, + "discount": None, + "email": userdata[2], + "license": "", + "mfa": False, + "name": userdata[1], + "payment": "", + "uid": userdata[0] + } + +def list_vaults(token:str): + uid = get_userid_by_token(token) + raw_data = db.execute_query("SELECT v.* FROM vaults v WHERE v.owner = %s", (uid,)) + vaults = [] + + for vault in raw_data: + size = 0 + + for file in glob("data/*"): + size += os.path.getsize(file) + + vaults.append({ + "created": int(vault[2].timestamp()*1000), + "encryption_version": 0, + "host": getenv("SYNC_SERVER_URL"), + "id": vault[0], + "name": vault[1], + "password": "", + "region": "Home", + "salt": "sugar", + "size": size + }) + + return {"limit":len(raw_data)+1,"shared":[],"vaults":vaults} + +def create_vault(name:str, token:str): + uid = get_userid_by_token(token) + db.execute_query("INSERT INTO vaults (name,owner) VALUES (%s, %s)", (name, uid)) + data = db.execute_query("SELECT * FROM vaults WHERE owner=%s AND name=%s ORDER BY created_at DESC LIMIT 1;", (uid,name,)) + data = data[0] + + return { + "created": int(data[2].timestamp()*1000), + "encrypted_version": 0, + "host": os.getenv("SYNC_SERVER_URL"), + "id": data[0], + "name": data[1], + "password": "", + "region": "Home", + "salt": "sugar", + "size": 0 + } + +def rename_vault(name:str, id:str, token:str): + uid = get_userid_by_token(token) + db.execute_query("UPDATE vaults SET name=%s WHERE id=%s AND owner=%s", (name, id, uid)) + return {} + +def delete_database(vault_id:str, token:str): + uid = get_userid_by_token(token) + db.execute_query("DELETE FROM vaults WHERE id=%s AND owner=%s", (vault_id, uid,)) + return {} + +def get_file(vault_id, path): + data = db.execute_query("SELECT * FROM vault_files WHERE vault_id=%s AND path=%s LIMIT 1", (vault_id, path,)) + return data + +def get_files(vault_id): + return db.execute_query("SELECT * FROM vault_files WHERE vault_id=%s", (vault_id,)) + +def add_file(vault_id:str, path:str, hash:str, user_id:int, file_content:bytes): + db.execute_query("INSERT INTO vaults_files (vault_id, path, hash, user_id, file_content) VALUES (%s, %s, %s, %s, %s)", + (vault_id, path, hash, user_id, file_content)) + +if __name__ == "__main__": + db.execute_query("""CREATE TABLE vaults_files ( + vault_id VARCHAR(255) NOT NULL REFERENCES vaults(id), + uid SERIAL PRIMARY KEY, + path TEXT, + hash TEXT, + user_id INTEGER, + file_content BYTEA, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + );""") + + db.execute_query("""CREATE TABLE IF NOT EXISTS users ( + uid VARCHAR(255) PRIMARY KEY DEFAULT gen_random_uuid()::text, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + token VARCHAR(255) + );""") + + db.execute_query("""CREATE TABLE IF NOT EXISTS vaults ( + id VARCHAR(255) PRIMARY KEY DEFAULT gen_random_uuid()::text, + name VARCHAR(255) NOT NULL DEFAULT 'Unnamed', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + owner VARCHAR(255) NOT NULL REFERENCES users(uid), + shared VARCHAR(255)[] DEFAULT '{}' + );""") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9b3bd09 --- /dev/null +++ b/main.py @@ -0,0 +1,269 @@ +from flask import Flask, request, make_response +from dotenv import load_dotenv +from flask_sock import Sock +from glob import glob +import database +import json +import os + +load_dotenv() +app = Flask(__name__) +sock = Sock(app) + +def make_resp(data=''): + response = make_response(data) + + response.status_code = 200 + response.headers['access-control-allow-origin'] = 'app://obsidian.md' + response.headers['access-control-allow-headers'] = 'content-type' + + return response + +index = json.load(open("index.json", "r", encoding="utf-8")) + +def save_index(): + with open("index.json", "w+") as f: + json.dump(index, f, indent=4) + +@sock.route("/") +def websocket(ws): + vault_id = None + device_name = "unknown" + while True: + raw = ws.receive() + data = json.loads(raw) + + operation = data.get("op") + + if operation == "init": + ws.send(json.dumps({"res": "ok", "perFileMax": 2147483647, "userId": 1})) + ws.send(json.dumps({"op": "ready", "version": 11})) + + for vault in database.list_vaults(data.get("token"))['vaults']: + if vault['id'] != vault_id: + ws.close() + + vault_id = data.get("id") + device_name = data.get("device", "unknown") + for path, data in index["files"].items(): + filesize = os.path.getsize(f"data/{path}") + ws.send(json.dumps({ + "op": "push", + "path": path, + "hash": data['hash'], + "size": filesize, + "ctime": data["ctime"], + "mtime": data["mtime"], + "folder": False, + "device": device_name, + "uid": index["id"].index(path) + })) + + for path, data in index["folders"].items(): + ws.send(json.dumps({ + "op": "push", + "path": path, + "hash": data['hash'], + "size": 0, + "ctime": 0, + "mtime": 0, + "folder": True, + "device": device_name, + "uid": index["id"].index(path) + })) + + if operation == "pull": + uid = data['uid'] + path = index["id"][uid] + ws.send(json.dumps({ + "hash": index["files"][path]['hash'], + "size": os.path.getsize(f"data/{path}"), + "pieces": 1 + })) + ws.send(open(f'data/{path}', "rb").read()) + + if operation == "ping": + ws.send(json.dumps({"op":"pong"})) + + if operation == "size": + size = 0 + for file in glob("data/*"): + size += os.path.getsize(file) + ws.send(json.dumps({"res":"ok","size":size,"vault_size":0,"limit":1099511627776})) # 1To + + if operation == "deleted": + return {"items":[]} + + if operation == "push": + path = data.get("path") + hash = data.get("hash") + + if data.get("deleted", False): + index["id"].remove(path) + del index["files"][path] + + index["id"].append(path) + if data.get("folder", False): + index["folders"][path] = {"hash": hash} + save_index() + continue + else: + index["files"][path] = { + "hash": hash, + "ctime": data.get("ctime"), + "mtime": data.get("mtime"), + "device": device_name + } + save_index() + + if data.get("pieces") == 1: + # Client will give us file content + ws.send(json.dumps({"res":"next"})) + file = ws.receive() + with open(f"data/{path}", "wb+") as f: + f.write(file) + + ws.send(raw) + ws.send(json.dumps({"op":"ok"})) + + if operation == "usernames": # TODO + ws.send(json.dumps({"1": "Mathias"})) + +@app.route("/user/info", methods=["POST", "OPTIONS"]) +def user_info(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + return make_resp(database.get_userinfo(data.get("token"))) + +@app.route("/user/signout", methods=["POST", "OPTIONS"]) +def user_signout(): + if request.method == "OPTIONS": return make_resp() + return make_resp({}) + +@app.route("/user/signin", methods=["POST", "OPTIONS"]) +def user_signin(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + return make_resp( + database.login(data["email"], data["password"]) + ) + +@app.route("/vault/list", methods=["POST", "OPTIONS"]) +def vault_list(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + return make_resp(database.list_vaults(data.get("token"))) + +@app.route("/vault/regions", methods=["POST", "OPTIONS"]) +def vault_regions(): + if request.method == "OPTIONS": return make_resp() + + return make_resp({ + "regions": [ + { + "name": "Home", + "value": "home" + } + ] + }) + +@app.route("/vault/create", methods=["POST", "OPTIONS"]) +def vault_create(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + if data.get("encryption_version") != 0: + return {"error": "End2End encryption not supported"} + + return make_resp(database.create_vault(data.get("name", "my awesome vault"), data.get("token"))) + +@app.route("/subscription/business", methods=["POST", "OPTIONS"]) +def subscription_business(): + if request.method == "OPTIONS": return make_resp() + return {} + +@app.route("/subscription/list", methods=["POST", "OPTIONS"]) +def subscription_list(): + if request.method == "OPTIONS": return make_resp() + return make_resp({"business":None,"publish":None,"sync":{"earlybird":False,"expiry_ts":1747156338125,"plan":"basic_1","renew":""},"syncPlans":[{"code":"basic_1","display":"Standard 1 GB","monthly":500,"perFileMax":6291456,"revisionHistoryDays":31,"storage":1073741824,"vaults":1,"yearly":4800},{"code":"standard_10","display":"Plus 10 GB","monthly":1000,"perFileMax":209715200,"revisionHistoryDays":365,"storage":10737418240,"vaults":10,"yearly":9600},{"code":"standard_100","display":"Plus 100 GB","monthly":2000,"perFileMax":209715200,"revisionHistoryDays":365,"storage":107374182400,"vaults":10,"yearly":19200}]}) + +@app.route("/vault/access", methods=["POST", "OPTIONS"]) +def vault_access(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + userinfo = database.get_userinfo(data.get("token")) + + return make_resp({ + "allowed": True, + "email": userinfo['email'], + "encryption_version": 0, + "name": userinfo['name'], + "useruid": userinfo['uid'] + }) + +@app.route("/vault/delete", methods=["POST", "OPTIONS"]) +def vault_delete(): + if request.method == "OPTIONS": return make_resp() + data = request.json + + return make_resp(database.delete_database(data.get("vault_uid"), data.get("token"))) + +@app.route("/vault/rename", methods=["POST", "OPTIONS"]) +def vault_rename(): + if request.method == "OPTIONS": return make_resp() + data = request.json + return make_resp(database.rename_vault(data.get("name"), data.get("vault_uid"), data.get("token"))) + +@app.route("/vault/share/list", methods=["POST", "OPTIONS"]) +def vault_share_list(): + if request.method == "OPTIONS": return make_resp() + return make_resp(json.dumps({"shares":[]})) + """ + POST:{ + "vault_uid": "", + +token + } + RESPONSE:{ + "shares": [ + { + "accepted": false, + "code": "", + "email": "", + "uid": "" + } + ] + } + """ + +@app.route("/vault/share/invite", methods=["POST", "OPTIONS"]) +def vault_share_invite(): + if request.method == "OPTIONS": return make_resp() + return make_resp(json.dumps({})) + """ + POST:{ + "email": "", + "vault_uid": "", + +token + } + RESPONSE:{} + """ + +@app.route("/vault/share/remove", methods=["POST", "OPTIONS"]) +def vault_share_remove(): + """ + POST:{ + "share_uid": "", + "vault_uid": "" + +token + } + RESPONSE:{} + """ + +sock.init_app(app) + +if __name__ == "__main__": + app.run(host=os.getenv("HOST"), port=os.getenv("PORT")) \ No newline at end of file diff --git a/redirect.py b/redirect.py new file mode 100644 index 0000000..ca5f8e1 --- /dev/null +++ b/redirect.py @@ -0,0 +1,19 @@ +from mitmproxy import http + +class ObsidianRedirect: + def request(self, flow: http.HTTPFlow) -> None: + #return + if flow.request.pretty_host == "sync-32.obsidian.md": + flow.request.scheme = "http" + flow.request.host = "localhost" + flow.request.port = 5000 + flow.request.headers["Host"] = "sync-32.obsidian.md" + + if flow.request.pretty_host == "api.obsidian.md": + flow.request.scheme = "http" + flow.request.host = "localhost" + flow.request.port = 5000 + + flow.request.headers["Host"] = "api.obsidian.md" + +addons = [ObsidianRedirect()] \ No newline at end of file