Remove project OAuth hardcodes
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
+44
-13
@@ -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:
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -393,4 +393,22 @@
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const scrollKey = "workers-scroll-y";
|
||||
const restoreY = sessionStorage.getItem(scrollKey);
|
||||
if (restoreY !== null) {
|
||||
sessionStorage.removeItem(scrollKey);
|
||||
requestAnimationFrame(() => window.scrollTo(0, Number(restoreY) || 0));
|
||||
}
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
const action = form.getAttribute("action") || "";
|
||||
if (action.startsWith("/workers") || action.startsWith("/settings") || action.includes("-schedule/")) {
|
||||
sessionStorage.setItem(scrollKey, String(window.scrollY));
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user