From f2effe002d72d427eda6947218c572e777f2c0fb Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 3 Aug 2026 17:23:51 +0500 Subject: [PATCH] Remove project OAuth hardcodes --- .env.example | 6 ++- MULTI_PROJECT.md | 43 ++++++++++++++++++ src/vk_parser_app/admin.py | 57 ++++++++++++++++++------ src/vk_parser_app/config.py | 4 ++ src/vk_parser_app/templates/workers.html | 18 ++++++++ src/vk_parser_app/vk_api.py | 6 ++- 6 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 MULTI_PROJECT.md diff --git a/.env.example b/.env.example index ceb1d13..b421868 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ENV=production APP_SECRET_KEY=change-me ADMIN_SITE_TITLE=RAA Редакторская ADMIN_APP_TITLE=RAA Редакторская +ADMIN_FAVICON_PATH= ADMIN_BOOTSTRAP_LOGIN=admin ADMIN_BOOTSTRAP_PASSWORD=change-me-long-password @@ -15,6 +16,9 @@ VK_ACCESS_TOKEN= VK_GROUP_ACCESS_TOKEN= VK_API_VERSION=5.199 VK_STORAGE_GROUP_ID=0 +VK_OAUTH_CLIENT_ID= +VK_OAUTH_REDIRECT_URI= +VK_OAUTH_ORIGIN= TG_BOT_TOKEN= TG_MEDIA_CHANNEL_ID= @@ -24,4 +28,4 @@ TELEGRAM_API_HASH= ADMIN_HOST=0.0.0.0 ADMIN_PORT=8080 -LOG_LEVEL=INFO +LOG_LEVEL=INFO \ No newline at end of file diff --git a/MULTI_PROJECT.md b/MULTI_PROJECT.md new file mode 100644 index 0000000..3ad01ba --- /dev/null +++ b/MULTI_PROJECT.md @@ -0,0 +1,43 @@ +# Multi-project setup + +Goal: keep one codebase and run separate deployments for each editorial project. + +## Target model + +- One canonical git repository for application code. +- One Coolify application per project. +- One Postgres database per project. +- Project differences live in Coolify env variables and database settings, not in code. + +## Per-project env + +- `ADMIN_SITE_TITLE` +- `ADMIN_APP_TITLE` +- `ADMIN_FAVICON_PATH` +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +- `APP_SECRET_KEY` +- `ADMIN_BOOTSTRAP_LOGIN`, `ADMIN_BOOTSTRAP_PASSWORD` +- `VK_ACCESS_TOKEN` +- `VK_GROUP_ACCESS_TOKEN` +- `VK_STORAGE_GROUP_ID` +- `VK_OAUTH_CLIENT_ID` +- `VK_OAUTH_REDIRECT_URI` +- `VK_OAUTH_ORIGIN` +- `TG_BOT_TOKEN` +- `TG_MEDIA_CHANNEL_ID` +- `LOCAL_BOT_API_URL` + +## Per-project database settings + +Sources, target groups/channels, poster tokens, schedules, prompts, categories, and enabled workers should stay in `app_settings`, `sources`, `content_categories`, and `worker_controls`. + +## Poster modules + +The site poster is selected by `site_poster_provider`. A project can disable the worker or leave the provider empty when there is no website poster yet. New website integrations should be added as provider modules behind the same interface instead of branching the worker by project name. + +## Migration path + +1. Finish removing project-specific constants from code. +2. Choose one canonical repo for both deployments. +3. Point both Coolify applications to that repo and branch. +4. Keep only env and database settings different between projects. diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index a4e34d5..228b50e 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -43,7 +43,7 @@ VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier" VK_OAUTH_STATE_COOKIE = "vk_oauth_state" BASE_DIR = Path(__file__).resolve().parent PROJECT_ROOT = BASE_DIR.parents[1] -FAVICON_PATH = PROJECT_ROOT / "favi.png" +FAVICON_PATH = Path(settings.admin_favicon_path).resolve() if settings.admin_favicon_path.strip() else PROJECT_ROOT / "favi.png" templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) app = FastAPI(title=settings.display_site_title) LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg") @@ -54,6 +54,22 @@ EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True) app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads") +def public_origin(request: Request) -> str: + configured = settings.vk_oauth_origin.strip().rstrip("/") + if configured: + return configured + proto = request.headers.get("x-forwarded-proto") or request.url.scheme + host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + return f"{proto}://{host}".rstrip("/") + + +def oauth_redirect_uri(request: Request, path: str) -> str: + configured = settings.vk_oauth_redirect_uri.strip() + if configured and path == "/vk-oauth/callback": + return configured + return f"{public_origin(request)}{path}" + + @app.get("/favi.png") async def favicon_png(): return FileResponse(FAVICON_PATH) @@ -882,7 +898,15 @@ def parse_source_line(line: str) -> dict[str, str]: if not raw: return {"name": "", "tag": "", "url": "", "error": "Пустая строка"} parts = re.split(r"\s+", raw) - url_index = next((i for i, part in enumerate(parts) if "vk.com/" in part or part.startswith("club")), -1) + url_index = next( + ( + i + for i, part in enumerate(parts) + if any(domain in part for domain in ("vk.com/", "vk.ru/", "m.vk.com/")) + or part.lower().startswith(("club", "public")) + ), + -1, + ) if url_index < 0: if len(parts) == 1: value = parts[0].strip() @@ -900,8 +924,9 @@ def parse_source_line(line: str) -> dict[str, str]: else: name = "" tag = "" - if url.startswith("vk.com/"): + if url.startswith(("vk.com/", "vk.ru/", "m.vk.com/")): url = f"https://{url}" + url = url.replace("https://vk.ru/", "https://vk.com/", 1).replace("https://m.vk.com/", "https://vk.com/", 1) return {"name": name, "tag": normalize_hash_tag(tag, ""), "url": url, "error": ""} @@ -1757,19 +1782,22 @@ async def vk_oauth_callback(request: Request, code: str = "", error: str = "", e @app.get("/vk/oauth/start") -async def vk_oauth_start() -> RedirectResponse: +async def vk_oauth_start(request: Request) -> RedirectResponse: + client_id = settings.vk_oauth_client_id.strip() or str(await fetch_setting("vk_poster_app_id", "") or "").strip() + if not client_id: + return redirect("/workers") verifier = secrets.token_urlsafe(64) state = secrets.token_urlsafe(24) - redirect_uri = "https://sw.exostring.xyz/vk/oauth/callback" + redirect_uri = oauth_redirect_uri(request, "/vk/oauth/callback") params = { - "client_id": "54635120", + "client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": "wall photos video groups offline", "state": state, "code_challenge": pkce_challenge(verifier), "code_challenge_method": "s256", - "origin": "https://sw.exostring.xyz", + "origin": public_origin(request), "v": "5.199", } response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}") @@ -1779,13 +1807,16 @@ async def vk_oauth_start() -> RedirectResponse: @app.get("/vk/group-oauth/start") -async def vk_group_oauth_start() -> RedirectResponse: +async def vk_group_oauth_start(request: Request) -> RedirectResponse: + client_id = settings.vk_oauth_client_id.strip() or str(await fetch_setting("vk_poster_app_id", "") or "").strip() + if not client_id: + return redirect("/workers") group_id = abs(int(settings.vk_storage_group_id)) params = { - "client_id": "54635120", + "client_id": client_id, "group_ids": str(group_id), "display": "page", - "redirect_uri": "https://sw.exostring.xyz/vk/oauth/callback", + "redirect_uri": oauth_redirect_uri(request, "/vk/oauth/callback"), "scope": "manage,photos,docs", "response_type": "token", "state": secrets.token_urlsafe(24), @@ -1804,7 +1835,7 @@ async def vk_poster_oauth_start(request: Request) -> RedirectResponse: return redirect("/workers") verifier = secrets.token_urlsafe(64) state = secrets.token_urlsafe(24) - redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback" + redirect_uri = oauth_redirect_uri(request, "/vk-oauth/callback") params = { "client_id": client_id, "redirect_uri": redirect_uri, @@ -1813,7 +1844,7 @@ async def vk_poster_oauth_start(request: Request) -> RedirectResponse: "state": state, "code_challenge": pkce_challenge(verifier), "code_challenge_method": "s256", - "origin": "https://sw.exostring.xyz", + "origin": public_origin(request), "v": "5.199", } response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}") @@ -1850,7 +1881,7 @@ async def vk_poster_oauth_callback( client_id = str(await fetch_setting("vk_poster_app_id", "") or "").strip() client_secret = str(await fetch_setting("vk_poster_client_secret", "") or "").strip() owner_id = int(await fetch_int_setting("vk_poster_owner_id", 0)) - redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback" + redirect_uri = oauth_redirect_uri(request, "/vk-oauth/callback") code_verifier = request.cookies.get(VK_OAUTH_VERIFIER_COOKIE, "") device_id = request.query_params.get("device_id", "") try: diff --git a/src/vk_parser_app/config.py b/src/vk_parser_app/config.py index d83bda6..17906db 100644 --- a/src/vk_parser_app/config.py +++ b/src/vk_parser_app/config.py @@ -8,6 +8,7 @@ class Settings(BaseSettings): app_secret_key: str = "change-me" admin_site_title: str = "" admin_app_title: str = "Редакторская" + admin_favicon_path: str = "" admin_bootstrap_login: str = "admin" admin_bootstrap_password: str = "" @@ -21,6 +22,9 @@ class Settings(BaseSettings): vk_group_access_token: str = "" vk_api_version: str = "5.199" vk_storage_group_id: int = 0 + vk_oauth_client_id: str = "" + vk_oauth_redirect_uri: str = "" + vk_oauth_origin: str = "" tg_bot_token: str = "" tg_media_channel_id: str = "" diff --git a/src/vk_parser_app/templates/workers.html b/src/vk_parser_app/templates/workers.html index 838a33f..13c40d0 100644 --- a/src/vk_parser_app/templates/workers.html +++ b/src/vk_parser_app/templates/workers.html @@ -393,4 +393,22 @@ {% endfor %} + {% endblock %} diff --git a/src/vk_parser_app/vk_api.py b/src/vk_parser_app/vk_api.py index 8718add..0183ddb 100644 --- a/src/vk_parser_app/vk_api.py +++ b/src/vk_parser_app/vk_api.py @@ -192,8 +192,10 @@ def normalize_vk_source(value: str) -> str: raw = str(value or "").strip() if not raw: return "" - if "vk.com" in raw: - raw = raw.split("vk.com", 1)[1] + for host in ("vk.com", "vk.ru", "m.vk.com"): + if host in raw: + raw = raw.split(host, 1)[1] + break raw = raw.lstrip("/") raw = raw.split("?", 1)[0].split("#", 1)[0].strip() raw = re.sub(r"^(club|public)", "", raw, flags=re.IGNORECASE)