commit 5f102a7820b1fa27193da703093f9f7ac5f11d89 Author: Khrysse <73716731+Khrysse@users.noreply.github.com> Date: Sun Jun 22 11:56:30 2025 +0200 Initial diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3d741f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLIENT_NAME="YourAPINme (Dev)/1.0" +VRCHAT_API_BASE="https://api.vrchat.cloud/api/1" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..739c436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +@" +__pycache__/ +*.pyc +venv/ +*.log +"@ | Out-File -Encoding utf8 .gitignore +.env +data/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfaf767 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ + + +A lightweight, fast, and secure **FastAPI** proxy server for the [VRChat API](https://vrchat.community/getting-started). +Designed to handle authentication, token management, and provide cached access to public and private VRChat data endpoints. + +--- + +## πŸš€ Features + +- **Seamless VRChat account authentication** with support for 2FA (email & TOTP) +- Automated token storage and expiry management (configurable cache duration) +- Public API endpoints to fetch VRChat groups & users info +- Private API endpoints secured by VRChat auth tokens +- Written in Python with FastAPI and HTTPX for async requests +- Auto environment setup with virtual environment creation & dependency installation +- Ready to deploy on any server with Python 3.8+ (tested with YunoHost) + +--- + +## πŸ’‘ Why this project? + +VRChat’s official API requires complex login flows and token management that can be cumbersome to implement for your apps or bots. +**K-API** simplifies this by handling authentication and caching internally, exposing easy REST endpoints for your applications. + +--- + +## πŸ› οΈ Getting Started + +### Requirements + +- Python 3.8 or higher installed globally +- Git (optional) + +### Installation & Running + +Clone this repository: + +```bash +git clone https://git.kvs.fyi/kryscau/VRChatAPI.git +# You can use the mirror with Github with: https://github.com/kryscau/VRChatAPI.git +cd VRChatAPI +``` + +Run the included Python bootstrap script to create and activate the virtual environment, install dependencies, authenticate your VRChat account, and start the server: + +```bash +python run.py +``` + +> This script will prompt for your VRChat username, password, and 2FA code if required. +> Tokens are stored securely and refreshed automatically every 30 days. + +### Access the API + +- Public endpoint example: + `GET /api/public/groups/{group_id}` + Returns info about a VRChat group. + +- Private endpoint example (requires authentication or specific permission in VRChat [join group, bans perms, ...]): + `GET /api/private/users/{user_id}` + Returns private user data accessible with your token. + +Explore interactive docs at: +`http://127.0.0.1:8000/docs` + +--- + +## πŸ”’ Security & Privacy + +- Your VRChat credentials and tokens are stored **locally** in JSON files inside the `data/auth/` directory. +- No credentials or tokens are ever sent to third-party servers. +- Use HTTPS and proper firewall rules when deploying publicly. + +--- + +## πŸ“ Project Structure + +``` +/app + /api # FastAPI route modules (public/private) +/data/auth # Token storage (auto-generated) +/prelaunch # Authentication helper scripts +run.py # Python bootstrapper script +requirements.txt # Python dependencies +README.md # This file +``` + +--- + +## 🀝 Contribution + +Feel free to open issues or submit pull requests. +Feature requests and bug reports are welcome! + +--- + +## ⚑ License + +MIT License Β© 2025 Kryscau (K-API) + +--- + +## πŸš€ Next Steps + +- Add WebSocket support for real-time VRChat events +- Implement caching layers with Redis or similar +- Dockerize for easy container deployments +- Add OAuth support for multi-user API proxies + +--- + +Made with ❀️ by [Kryscau](https://kryscau.github.io). diff --git a/app/api/vrchat_groups.py b/app/api/vrchat_groups.py new file mode 100644 index 0000000..94f20ba --- /dev/null +++ b/app/api/vrchat_groups.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, HTTPException +import httpx +import json +from app.env import CLIENT_NAME, API_BASE, TOKEN_FILE + +router = APIRouter() + +def load_token(): + if not TOKEN_FILE.exists(): + return None + with open(TOKEN_FILE, "r") as f: + return json.load(f) + +@router.get("/groups/{group_id}") +async def get_groups(group_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + params = { + "includeRoles": "true", + "purpose": "group" + } + url = f"{API_BASE}/groups/{group_id}" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies, params=params) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch group info: {r.text}") + + return r.json() + +@router.get("/groups/{group_id}/instances") +async def get_groups_instances(group_id: str, user_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + url = f"{API_BASE}/groups/{group_id}/instances" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch groups instances info: {r.text}") + + return r.json() + +@router.get("/groups/{group_id}/posts") +async def get_groups_posts(group_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + params = { + "n": "10", + "offset": "0", + "publicOnly": False + } + url = f"{API_BASE}/groups/{group_id}/posts" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies, params=params) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch groups posts info: {r.text}") + + return r.json() + + +@router.get("/groups/{group_id}/bans") +async def get_groups_bans(group_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + params = { + "n": "51", + "offset": "0" + } + url = f"{API_BASE}/groups/{group_id}/bans" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies, params=params) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch groups bans info: {r.text}") + + return r.json() + +@router.get("/groups/{group_id}/roles") +async def get_groups_roles(group_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + url = f"{API_BASE}/groups/{group_id}/roles" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch groups roles info: {r.text}") + + return r.json() + +@router.get("/groups/{group_id}/members") +async def get_groups_members(group_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + params = { + "n": "25", + "offset": "0" + } + url = f"{API_BASE}/groups/{group_id}/members" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies, params=params) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch groups members info: {r.text}") + + return r.json() \ No newline at end of file diff --git a/app/api/vrchat_users.py b/app/api/vrchat_users.py new file mode 100644 index 0000000..bf4f5af --- /dev/null +++ b/app/api/vrchat_users.py @@ -0,0 +1,108 @@ +from fastapi import APIRouter, HTTPException +import httpx +import json +from app.env import CLIENT_NAME, API_BASE, TOKEN_FILE + +router = APIRouter() + +def load_token(): + if not TOKEN_FILE.exists(): + return None + with open(TOKEN_FILE, "r") as f: + return json.load(f) + +@router.get("/users/{user_id}") +async def get_user(user_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + url = f"{API_BASE}/users/{user_id}" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch user info: {r.text}") + + return r.json() + +@router.get("/users/{user_id}/friends/status") +async def get_user_friend_status(user_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + url = f"{API_BASE}/users/{user_id}/friendStatus" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch user friend status info: {r.text}") + + return r.json() + +@router.get("/users/{user_id}/worlds") +async def get_user_worlds(user_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + params = { + "releaseStatus": "public", + "sort": "updated", + "order": "descending", + "userId": user_id, + "n": "100", + "offset": "0" + } + url = f"{API_BASE}/worlds" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies, params=params) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch user worlds info: {r.text}") + + return r.json() + +@router.get("/users/{user_id}/groups") +async def get_user_groups(user_id: str): + token = load_token() + if not token: + raise HTTPException(status_code=401, detail="Token not found, please authenticate first") + + auth_cookie = token.get("auth_cookie") + if not auth_cookie: + raise HTTPException(status_code=401, detail="Auth cookie missing in token") + + headers = {"User-Agent": CLIENT_NAME} + cookies = {"auth": auth_cookie} + url = f"{API_BASE}/users/{user_id}/groups" + + async with httpx.AsyncClient() as client: + r = await client.get(url, headers=headers, cookies=cookies) + + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=f"Failed to fetch user groups info: {r.text}") + + return r.json() \ No newline at end of file diff --git a/app/env.py b/app/env.py new file mode 100644 index 0000000..11e77b5 --- /dev/null +++ b/app/env.py @@ -0,0 +1,8 @@ +from dotenv import load_dotenv +import os +from pathlib import Path +load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env") + +CLIENT_NAME = os.getenv("CLIENT_NAME", "default-client-name") +API_BASE = os.getenv("VRCHAT_API_BASE", "https://api.vrchat.cloud/api/1") +TOKEN_FILE = Path(os.getenv("TOKEN_FILE", "data/auth/account.json")) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0a1240d --- /dev/null +++ b/app/main.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from app.api.vrchat_users import router as users +from app.api.vrchat_groups import router as groups + +app = FastAPI( + title="K-API", + description=""" + K-API is a fast, secure, and lightweight proxy API for VRChat. + It handles authentication via VRChat’s official API, including 2FA support, + and provides cached endpoints for user and group information retrieval. + + This project is designed for developers who want a hassle-free way + to integrate VRChat data into their apps without managing sessions or tokens manually. + + Features: + - Automatic token management with 2FA handling + - Public and private VRChat data endpoints + - Response caching for performance + - Easy deployment on self-hosted servers (YunoHost compatible) + + Built with FastAPI and async HTTPX for high performance and reliability. + """, + swagger_ui_parameters={"defaultModelsExpandDepth": -1}, + docs_url="/docs", + redoc_url=None, + openapi_url="/openapi.json", + contact={"name": "Kryscau", "url": "https://vrchat.com/home/user/usr_323befe7-edbc-46fe-af9d-560f7e6b290c", "email": "kryscau@kvs.fyi" } +) +prefix = "/api" + +app.include_router(users, prefix=prefix, tags=["Users"]) +app.include_router(groups, prefix=prefix, tags=["Groups"]) \ No newline at end of file diff --git a/app/prelaunch/vrchat_auth.py b/app/prelaunch/vrchat_auth.py new file mode 100644 index 0000000..6889aa9 --- /dev/null +++ b/app/prelaunch/vrchat_auth.py @@ -0,0 +1,142 @@ +import httpx +import base64 +import json +from datetime import datetime, timedelta, timezone +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) +from env import CLIENT_NAME, API_BASE, TOKEN_FILE + +def save_token(data): + data["created_at"] = datetime.now(timezone.utc).isoformat() + TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(TOKEN_FILE, "w") as f: + json.dump(data, f, indent=4) + +def load_token(): + if not TOKEN_FILE.exists(): + return None + with open(TOKEN_FILE, "r") as f: + data = json.load(f) + created = datetime.fromisoformat(data.get("created_at", "2000-01-01T00:00:00+00:00")) + if datetime.now(timezone.utc) - created > timedelta(days=30): + print("⚠️ Token expired. Reconnection required.") + return None + return data + +def verify_auth_cookie(auth_cookie): + cookies = {"auth": auth_cookie} + headers = {"User-Agent": CLIENT_NAME} + with httpx.Client(base_url=API_BASE, cookies=cookies, headers=headers) as client: + r = client.get("/auth") + return r.status_code == 200 and r.json().get("ok", False) + +def login(): + print("πŸ” Connecting to VRChat") + manual_username = input("Username: ") + password = input("Password: ") + + creds = f"{manual_username}:{password}" + b64 = base64.b64encode(creds.encode()).decode() + auth_header = f"Basic {b64}" + + headers = { + "Authorization": auth_header, + "User-Agent": CLIENT_NAME + } + + with httpx.Client(base_url=API_BASE, headers=headers) as client: + # 1 - First GET call to /auth/user with Basic Auth + r = client.get("/auth/user") + if r.status_code != 200: + print("❌ Connection failed:", r.text) + return None + + data = r.json() + + # 2 - Check 2FA + if "requiresTwoFactorAuth" in data: + mfa_types = data["requiresTwoFactorAuth"] + print(f"πŸ” 2FA required: {mfa_types}") + + # The Authorization header is removed for the following request + client.headers.pop("Authorization", None) + + if "otp" in mfa_types: + code = input("Code 2FA (TOTP): ") + verify_endpoint = "/auth/twofactorauth/verify" + elif "emailOtp" in mfa_types: + code = input("Code 2FA (email): ") + verify_endpoint = "/auth/twofactorauth/emailotp/verify" + else: + print("❌ Unknown 2FA type:", mfa_types) + return None + + r2 = client.post(verify_endpoint, json={"code": code}) + + if r2.status_code != 200 or not r2.json().get("verified", False): + print("❌ 2FA verification failed:", r2.text) + return None + print("βœ… 2FA verified!") + + # 3 - Repeat a GET /auth/user without Basic Auth to confirm the session + r3 = client.get("/auth/user") + if r3.status_code != 200: + print("❌ Failed to fetch user data after 2FA:", r3.text) + return None + + data = r3.json() + + # 4 - Retrieve auth cookie + auth_cookie = None + for cookie in client.cookies.jar: + if cookie.name == "auth": + auth_cookie = cookie.value + break + + if not auth_cookie: + print("❌ Auth cookie not found after login.") + return None + + # 5 - Check cookie + if not verify_auth_cookie(auth_cookie): + print("❌ Auth cookie invalid.") + return None + + print("βœ… Connected and verified.") + + # 6 - Prepare the information to be backed up + display_name = data.get("displayName", manual_username) + user_id = data.get("id", "") + + return { + "manual_username": manual_username, + "displayName": display_name, + "user_id": user_id, + "auth": b64, + "auth_cookie": auth_cookie + } + +def get_or_create_token(): + token = load_token() + if token: + print("πŸ”‘ Found saved token, verifying...") + if verify_auth_cookie(token.get("auth_cookie", "")): + print("🟒 Token already valid.") + return token + else: + print("⚠️ Saved token invalid, need to login again.") + + new_token = login() + if new_token: + save_token(new_token) + return new_token + return None + +if __name__ == "__main__": + token_data = get_or_create_token() + if token_data: + print("πŸ”“ Auth ready. Token stored.") + else: + print("❌ Unable to obtain a valid token.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c3c1028 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +httpx +orjson +python-dotenv \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..4e5f8d6 --- /dev/null +++ b/run.py @@ -0,0 +1,56 @@ +import os +import sys +import subprocess +import venv + +VENV_DIR = "venv" +REQ_FILE = "requirements.txt" +PORT = "8000" + +# INFORMATIONS : This file does not require β€œvenv” to run, but make sure you have Python installed globally (py run.py on Windows, for example). +# INFORMATIONS : This file does not require β€œvenv” to run, but make sure you have Python installed globally (py run.py on Windows, for example). +# INFORMATIONS : This file does not require β€œvenv” to run, but make sure you have Python installed globally (py run.py on Windows, for example). +# INFORMATIONS : This file does not require β€œvenv” to run, but make sure you have Python installed globally (py run.py on Windows, for example). +# INFORMATIONS : This file does not require β€œvenv” to run, but make sure you have Python installed globally (py run.py on Windows, for example). + +def create_venv(): + print("Creating virtual environment...") + venv.create(VENV_DIR, with_pip=True) + +def run_in_venv(cmd): + if os.name == "nt": + # Windows + python_bin = os.path.join(VENV_DIR, "Scripts", "python.exe") + else: + # Unix + python_bin = os.path.join(VENV_DIR, "bin", "python") + full_cmd = [python_bin] + cmd + result = subprocess.run(full_cmd) + if result.returncode != 0: + print(f"Command {cmd} failed.") + sys.exit(result.returncode) + +def install_requirements(): + print("Installing dependencies...") + run_in_venv(["-m", "pip", "install", "--upgrade", "pip"]) + run_in_venv(["-m", "pip", "install", "-r", REQ_FILE]) + +def main(): + if not os.path.exists(VENV_DIR): + create_venv() + install_requirements() + else: + print("Virtual environment found.") + + print("Running VRChat authentication script...") + run_in_venv(["app/prelaunch/vrchat_auth.py"]) + + print("Starting FastAPI server...") + + HOST = "0.0.0.0" + RELOAD_FLAG = "--reload" + + run_in_venv(["-m", "uvicorn", "app.main:app", "--host", HOST, "--port", PORT] + ([RELOAD_FLAG] if RELOAD_FLAG else [])) + +if __name__ == "__main__": + main()