diff --git a/.env.example b/.env.example index dc55b81..571ed14 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ENV=production APP_SECRET_KEY=change-me ADMIN_SITE_TITLE=Редакторская ADMIN_APP_TITLE=Редакторская +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= 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)