diff --git a/app/main.py b/app/main.py index 04438a9..8de7f1c 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError from starlette.background import BackgroundTask from starlette.middleware.base import BaseHTTPMiddleware -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles # ── Config ──────────────────────────────────────────────────────────────────── @@ -66,6 +66,9 @@ SESSION_DAYS = 30 VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "") VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "") VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback" +# ── Админ-панель ────────────────────────────────────────────────────────────── +ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "") + # ── Вход по почте (magic-code через SMTP) ───────────────────────────────────── SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) @@ -296,6 +299,21 @@ def _db_ensure_auth_table(): attempts INTEGER DEFAULT 0 ) """) + # Счётчик заходов на сайт по дням (для админ-панели). + con.execute(""" + CREATE TABLE IF NOT EXISTS WEB_VISITS ( + day TEXT PRIMARY KEY, + count INTEGER DEFAULT 0 + ) + """) + # Уникальные посетители: (день, id браузера из cookie p2s_vid). + con.execute(""" + CREATE TABLE IF NOT EXISTS WEB_VISIT_IDS ( + day TEXT, + vid TEXT, + PRIMARY KEY (day, vid) + ) + """) # Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub. con.execute(""" CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( @@ -330,6 +348,17 @@ def _db_ensure_auth_table(): con.execute("ALTER TABLE FILES ADD COLUMN web_owner_id INTEGER DEFAULT 0") except Exception as _mig: print(f"FILES migrate error: {_mig}") + # Авто-миграция WEB_ACCOUNTS: авто-продление подписки (сохранённый способ оплаты) + try: + cur = con.execute("PRAGMA table_info(WEB_ACCOUNTS)") + acols = {row[1] for row in cur.fetchall()} + for _c, _ddl in (("autorenew", "INTEGER DEFAULT 0"), ("pm_id", "TEXT"), + ("pm_email", "TEXT"), ("renew_fail", "INTEGER DEFAULT 0"), + ("last_renew", "REAL DEFAULT 0")): + if _c not in acols: + con.execute(f"ALTER TABLE WEB_ACCOUNTS ADD COLUMN {_c} {_ddl}") + except Exception as _amig: + print(f"WEB_ACCOUNTS migrate error: {_amig}") con.commit() except Exception as e: print(f"WEB_AUTH_TOKENS create error: {e}") @@ -495,6 +524,7 @@ async def _startup(): os.makedirs(UPLOAD_DIR, exist_ok=True) _db_ensure_auth_table() threading.Thread(target=_cleanup_loop, daemon=True).start() + threading.Thread(target=_autorenew_loop, daemon=True).start() app.mount("/static", StaticFiles(directory="/web_app/static"), name="static") @@ -1633,16 +1663,21 @@ def _resolve_account(request, user_id=0) -> str | None: return _account_id(user_id) -def _db_sub_active(account_id: str | None) -> bool: +def _db_sub_until(account_id: str | None) -> float: + """Время окончания подписки (unix) или 0.""" if not account_id: - return False + return 0.0 try: with sqlite3.connect(DB_PATH, timeout=10) as con: cur = con.execute("SELECT sub_until FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,)) row = cur.fetchone() - return bool(row) and (row[0] or 0) > time.time() + return float(row[0] or 0) if row else 0.0 except Exception: - return False + return 0.0 + + +def _db_sub_active(account_id: str | None) -> bool: + return _db_sub_until(account_id) > time.time() def _db_activate_sub(account_id: str, days: int): @@ -1661,8 +1696,58 @@ def _db_activate_sub(account_id: str, days: int): con.commit() -def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None): - """extra: metadata платежа (job_id для file, account_id для sub).""" +def _db_set_autorenew(account_id, pm_id, email): + """Сохранить способ оплаты ЮKassa и включить автопродление для аккаунта.""" + if not account_id or not pm_id: + return + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "UPDATE WEB_ACCOUNTS SET autorenew=1, pm_id=?, " + "pm_email=COALESCE(NULLIF(?,''), pm_email), renew_fail=0 WHERE account_id=?", + (pm_id, email or "", account_id)) + con.commit() + except Exception as e: + print(f"set autorenew error: {e}") + + +def _db_get_autorenew(account_id) -> dict: + if not account_id: + return {"autorenew": False, "has_method": False} + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.row_factory = sqlite3.Row + row = con.execute("SELECT autorenew, pm_id FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,)).fetchone() + if not row: + return {"autorenew": False, "has_method": False} + return {"autorenew": bool(row["autorenew"]), "has_method": bool(row["pm_id"])} + except Exception: + return {"autorenew": False, "has_method": False} + + +def _db_set_autorenew_flag(account_id, enable: bool) -> dict: + """Вкл/выкл автопродление. Включить можно только при сохранённом способе оплаты.""" + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.row_factory = sqlite3.Row + row = con.execute("SELECT autorenew, pm_id FROM WEB_ACCOUNTS WHERE account_id=?", (account_id,)).fetchone() + if not row: + return {"autorenew": False, "has_method": False} + has_pm = bool(row["pm_id"]) + if enable and not has_pm: + return {"autorenew": bool(row["autorenew"]), "has_method": False} + con.execute("UPDATE WEB_ACCOUNTS SET autorenew=? WHERE account_id=?", + (1 if enable else 0, account_id)) + con.commit() + return {"autorenew": bool(enable), "has_method": has_pm} + except Exception as e: + print(f"toggle autorenew error: {e}") + return {"autorenew": False, "has_method": False} + + +def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_method=None): + """extra: metadata платежа (job_id для file, account_id для sub). + payment_method: объект payment_method из ЮKassa (для сохранения при автопродлении).""" extra = extra or {} try: with sqlite3.connect(DB_PATH, timeout=10) as con: @@ -1681,6 +1766,10 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None): acc = extra.get("account_id") if acc: _db_activate_sub(acc, SUB_DAYS) + # Автопродление: пользователь согласился и способ оплаты сохранён + if str(extra.get("autorenew")) in ("1", "true", "True") and \ + payment_method and payment_method.get("saved") and payment_method.get("id"): + _db_set_autorenew(acc, payment_method.get("id"), extra.get("email") or "") # И file, и sub: если платёж привязан к заданию — снимаем его с холда job_id = extra.get("job_id") if job_id and _db_get_url_res_f(job_id) == "awaiting_payment": @@ -1701,6 +1790,7 @@ async def pay_create(payload: dict, request: Request): api_key = (payload.get("api_key") or "").strip()[:80] or None job_id = (payload.get("job_id") or "").strip()[:128] or None account_id = _resolve_account(request, payload.get("user_id")) + autorenew = bool(payload.get("autorenew")) and purpose == "sub" if purpose == "api_pro": amount = API_PRO_PRICE_RUB @@ -1733,8 +1823,11 @@ async def pay_create(payload: dict, request: Request): "confirmation": {"type": "redirect", "return_url": f"{SITE_BASE_URL}/?paid={purpose}"}, "description": description, "metadata": {"purpose": purpose, "api_key": api_key or "", "email": email, - "job_id": job_id or "", "account_id": account_id or ""}, + "job_id": job_id or "", "account_id": account_id or "", + "autorenew": "1" if autorenew else ""}, } + if autorenew: + body["save_payment_method"] = True # сохранить способ оплаты для авто-списаний if email: # чек 54-ФЗ (нужна подключённая онлайн-касса в ЮKassa) body["receipt"] = { "customer": {"email": email}, @@ -1769,7 +1862,7 @@ async def yookassa_webhook(payload: dict): amount = float((real.get("amount") or {}).get("value") or 0) _db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "") if status == "succeeded": - _db_mark_payment_paid(pid, purpose, api_key, extra=meta) + _db_mark_payment_paid(pid, purpose, api_key, extra=meta, payment_method=real.get("payment_method")) return {"ok": True} @@ -1800,7 +1893,7 @@ async def pay_status(payment_id: str): rk = meta.get("api_key") or api_key _db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "") if status == "succeeded": - _db_mark_payment_paid(payment_id, rp, rk, extra=meta) + _db_mark_payment_paid(payment_id, rp, rk, extra=meta, payment_method=real.get("payment_method")) return {"status": status or db_status, "purpose": purpose} except Exception: return {"status": db_status, "purpose": purpose} @@ -1833,6 +1926,97 @@ async def paywall_check(payload: dict, request: Request): } +# ── Авто-продление подписки (рекуррентные списания ЮKassa) ───────────────────── +def _bump_renew_fail(account_id): + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute("UPDATE WEB_ACCOUNTS SET renew_fail=renew_fail+1 WHERE account_id=?", (account_id,)) + con.commit() + except Exception: + pass + + +def _charge_renewal(acc_row: dict): + """Рекуррентное списание за подписку по сохранённому способу оплаты (payment_method_id).""" + account_id = acc_row["account_id"] + # отметим попытку сразу, чтобы не долбить при ошибке (guard раз в 12ч) + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute("UPDATE WEB_ACCOUNTS SET last_renew=? WHERE account_id=?", (time.time(), account_id)) + con.commit() + except Exception: + pass + value = f"{SUB_PRICE_RUB:.2f}" + email = acc_row.get("pm_email") or "" + body = { + "amount": {"value": value, "currency": "RUB"}, + "capture": True, + "payment_method_id": acc_row["pm_id"], + "description": "Подписка pdf2scan (автопродление, 30 дней)", + "metadata": {"purpose": "sub", "account_id": account_id, "autorenew": "1", + "renewal": "1", "email": email}, + } + if email: + body["receipt"] = {"customer": {"email": email}, "items": [{ + "description": "Подписка pdf2scan (30 дней)", "quantity": "1.00", + "amount": {"value": value, "currency": "RUB"}, + "vat_code": 1, "payment_mode": "full_payment", "payment_subject": "service"}]} + # идемпотентность по периоду подписки — защита от двойного списания при повторе цикла + idem = hashlib.sha256(f"renew:{account_id}:{int(acc_row.get('sub_until') or 0)}".encode()).hexdigest() + try: + real = _yk_request("POST", "/payments", body, idem) + except Exception as e: + print(f"renew charge error {account_id}: {e}") + _bump_renew_fail(account_id) + return + pid, status = real.get("id"), real.get("status") + _db_record_payment(pid, SUB_PRICE_RUB, "RUB", status or "pending", "sub", None, email) + if status == "succeeded": + _db_mark_payment_paid(pid, "sub", None, extra=real.get("metadata") or {}, + payment_method=real.get("payment_method")) + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute("UPDATE WEB_ACCOUNTS SET renew_fail=0 WHERE account_id=?", (account_id,)) + con.commit() + except Exception: + pass + print(f"autorenew OK: {account_id}") + elif status == "canceled": + _bump_renew_fail(account_id) + print(f"autorenew canceled: {account_id}") + # status pending → активацию довершит вебхук успешного платежа + + +def _run_autorenew_once(): + if not (YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY): + return + now = time.time() + with sqlite3.connect(DB_PATH, timeout=15) as con: + con.row_factory = sqlite3.Row + rows = [dict(r) for r in con.execute( + "SELECT account_id, sub_until, pm_id, pm_email, renew_fail, last_renew FROM WEB_ACCOUNTS " + "WHERE autorenew=1 AND pm_id IS NOT NULL AND pm_id != '' " + "AND sub_until < ? AND sub_until > ? AND renew_fail < 3 AND COALESCE(last_renew,0) < ?", + (now + 2 * 86400, now - 3 * 86400, now - 12 * 3600))] + for r in rows: + try: + _charge_renewal(r) + except Exception as e: + print(f"autorenew row error: {e}") + + +def _autorenew_loop(): + """Фоновый цикл: раз в 6ч списывает подписку у аккаунтов с автопродлением, + у которых срок истекает в ближайшие 2 дня. Списание ДО истечения — без разрыва.""" + time.sleep(120) # дать приложению прогреться + while True: + try: + _run_autorenew_once() + except Exception as e: + print(f"autorenew loop error: {e}") + time.sleep(6 * 3600) + + # ══════════════════════════════════════════════════════════════════════════════ # Аккаунты, сессии, OAuth-вход (Yandex ID) # ══════════════════════════════════════════════════════════════════════════════ @@ -1956,6 +2140,29 @@ async def me(request: Request): "email": sess["email"], "sub_active": _db_sub_active(sess["account_id"])} +@app.get("/api/subscription") +async def subscription_status(request: Request, user_id: int = 0): + """Статус подписки для текущего входа: cookie-сессия (Yandex/VK) ИЛИ Telegram user_id.""" + acc = _resolve_account(request, user_id) + until = _db_sub_until(acc) + ar = _db_get_autorenew(acc) + return {"active": until > time.time(), "until": until, + "price": SUB_PRICE_RUB, "paywall_enabled": PAYWALL_ENABLED, + "autorenew": ar["autorenew"], "has_method": ar["has_method"]} + + +@app.post("/api/subscription/autorenew") +async def subscription_autorenew(payload: dict, request: Request): + """Вкл/выкл автопродление. body: {enable: bool, user_id?}.""" + if not isinstance(payload, dict): + payload = {} + acc = _resolve_account(request, payload.get("user_id")) + if not acc: + raise HTTPException(status_code=401, detail="Нужен вход в аккаунт") + res = await asyncio.to_thread(_db_set_autorenew_flag, acc, bool(payload.get("enable"))) + return res + + @app.post("/api/auth/logout") async def auth_logout(request: Request): _db_delete_session(request.cookies.get(SESSION_COOKIE)) @@ -2336,6 +2543,355 @@ _API_DOCS_HTML = """ """ +_ADMIN_HTML = """ + + + + + +pdf2scan · админ + + + +
+

Админ-панель

+

Введите админ-токен

+ +
+ +
+ +
+
+

pdf2scan · админ

+ + + + +
+ +
+
Активные подписки
+
Доход (успешно)
+
Заходы сегодня
+
Документов обработано
+
+ +
+

Активные подписки

+
+
АккаунтАктивна доОсталось
+
+ +
+

Заходы на сайт

+
+
+ +
+

Платежи

+
+
ДатаСуммаНазначениеСтатусEmail
+
+ +
+
+

Обработанные документы

+ +
+
+
ДатаФайлСтр.ИсходникРезультатКач-воOCRИсточник
+
+
+ + + +""" + + +# ── Админ-панель ────────────────────────────────────────────────────────────── +def _bump_visit(vid: str | None = None): + """+1 к счётчику заходов за сегодня; vid — id браузера для подсчёта уникальных.""" + try: + day = datetime.datetime.now().strftime("%Y-%m-%d") + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "INSERT INTO WEB_VISITS (day, count) VALUES (?, 1) " + "ON CONFLICT(day) DO UPDATE SET count = count + 1", + (day,), + ) + if vid: + con.execute("INSERT OR IGNORE INTO WEB_VISIT_IDS (day, vid) VALUES (?, ?)", (day, vid)) + con.commit() + except Exception: + pass + + +@app.post("/api/hit") +async def api_hit(request: Request): + """Маячок посещения: JS вызывает раз в сессию браузера. Боты/health-check без JS не считаются. + Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей.""" + vid = request.cookies.get("p2s_vid") or "" + new_vid = False + if len(vid) < 8: + vid = secrets.token_urlsafe(12) + new_vid = True + await asyncio.to_thread(_bump_visit, vid) + resp = JSONResponse({"ok": True}) + if new_vid: + resp.set_cookie("p2s_vid", vid, max_age=60 * 60 * 24 * 730, + httponly=True, secure=True, samesite="lax", path="/") + return resp + + +def _require_admin(request: Request) -> bool: + if not ADMIN_TOKEN: + return False + tok = request.headers.get("X-Admin-Token") or request.query_params.get("token") or "" + try: + return hmac.compare_digest(str(tok), ADMIN_TOKEN) + except Exception: + return False + + +def _admin_collect_stats() -> dict: + now = time.time() + out = {"generated_at": now, "web_user_id": WEB_USER_ID} + with sqlite3.connect(DB_PATH, timeout=15) as con: + con.row_factory = sqlite3.Row + # Активные подписки + subs = [dict(r) for r in con.execute( + "SELECT account_id, sub_until FROM WEB_ACCOUNTS WHERE sub_until > ? ORDER BY sub_until DESC", + (now,))] + out["subs"] = {"active_count": len(subs), "list": subs[:300]} + # Платежи + pays = [dict(r) for r in con.execute( + "SELECT payment_id, amount, currency, status, purpose, email, created_at, paid_at " + "FROM WEB_PAYMENTS ORDER BY created_at DESC LIMIT 100")] + srow = con.execute( + "SELECT COUNT(*) c, COALESCE(SUM(amount),0) s FROM WEB_PAYMENTS WHERE status='succeeded'").fetchone() + by_purpose = {r["purpose"]: {"count": r["c"], "sum": r["s"]} for r in con.execute( + "SELECT purpose, COUNT(*) c, COALESCE(SUM(amount),0) s FROM WEB_PAYMENTS " + "WHERE status='succeeded' GROUP BY purpose")} + out["payments"] = {"recent": pays, "count_succeeded": srow["c"], + "total_succeeded": srow["s"], "by_purpose": by_purpose} + # Заходы + уникальные посетители + days = [dict(r) for r in con.execute( + "SELECT day, count FROM WEB_VISITS ORDER BY day DESC LIMIT 30")] + udays = {r["day"]: r["u"] for r in con.execute( + "SELECT day, COUNT(*) u FROM WEB_VISIT_IDS GROUP BY day")} + for d in days: + d["uniques"] = udays.get(d["day"], 0) + today = datetime.datetime.now().strftime("%Y-%m-%d") + today_c = next((d["count"] for d in days if d["day"] == today), 0) + last7 = sum(d["count"] for d in days[:7]) + total = con.execute("SELECT COALESCE(SUM(count),0) s FROM WEB_VISITS").fetchone()["s"] + uniq_total = con.execute("SELECT COUNT(DISTINCT vid) c FROM WEB_VISIT_IDS").fetchone()["c"] + uniq_today = udays.get(today, 0) + uniq_last7 = sum(udays.get(d["day"], 0) for d in days[:7]) + out["visits"] = {"today": today_c, "last7": last7, "total": total, "days": days, + "unique_today": uniq_today, "unique_last7": uniq_last7, "unique_total": uniq_total} + # Документы + cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")} + pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)" + docs = [dict(r) for r in con.execute( + f"SELECT date, time, COALESCE(NULLIF(original_name,''), name_or_f) AS name, {pages_expr} AS pages, " + "source_size_mb, result_size_mb, quality, ocr, user_id, url_res_f " + "FROM FILES ORDER BY rowid DESC LIMIT 150")] + agg = con.execute( + f"SELECT COUNT(*) c, COALESCE(SUM({pages_expr}),0) p, " + "COALESCE(SUM(source_size_mb),0) ssz, COALESCE(SUM(result_size_mb),0) rsz FROM FILES").fetchone() + out["docs"] = {"recent": docs, "total_count": agg["c"], "total_pages": agg["p"], + "total_source_mb": agg["ssz"], "total_result_mb": agg["rsz"]} + return out + + +@app.get("/api/admin/stats") +async def admin_stats(request: Request): + if not _require_admin(request): + raise HTTPException(status_code=401, detail="unauthorized") + return await asyncio.to_thread(_admin_collect_stats) + + +def _build_docs_csv() -> bytes: + import csv, io + buf = io.StringIO() + w = csv.writer(buf, delimiter=";") + w.writerow(["Дата", "Время", "Файл", "Страниц", "Исходник, МБ", + "Результат, МБ", "Качество", "OCR", "Источник", "Статус"]) + def _mb(v): + return ("%.2f" % v) if v else "" + with sqlite3.connect(DB_PATH, timeout=30) as con: + con.row_factory = sqlite3.Row + cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")} + pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)" + for r in con.execute( + f"SELECT date, time, COALESCE(NULLIF(original_name,''), name_or_f) AS name, {pages_expr} AS pages, " + "source_size_mb, result_size_mb, quality, ocr, user_id, url_res_f FROM FILES ORDER BY rowid DESC"): + src = "сайт" if (r["user_id"] == WEB_USER_ID) else "бот" + w.writerow([r["date"] or "", (r["time"] or "")[:8], r["name"] or "", r["pages"] or "", + _mb(r["source_size_mb"]), _mb(r["result_size_mb"]), r["quality"] or "", + "да" if r["ocr"] else "", src, r["url_res_f"] or ""]) + return ("" + buf.getvalue()).encode("utf-8") # BOM → кириллица в Excel + + +@app.get("/api/admin/docs.csv") +async def admin_docs_csv(request: Request): + if not _require_admin(request): + raise HTTPException(status_code=401, detail="unauthorized") + data = await asyncio.to_thread(_build_docs_csv) + return Response(content=data, media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=pdf2scan_documents.csv"}) + + +@app.get("/admin", response_class=HTMLResponse) +@app.get("/admin/", response_class=HTMLResponse) +async def admin_page(): + return _ADMIN_HTML + + @app.get("/developers", response_class=HTMLResponse) @app.get("/api-docs", response_class=HTMLResponse) async def developers_docs(): diff --git a/app/static/index.html b/app/static/index.html index ed668ce..ae6a039 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -850,8 +850,8 @@
@@ -910,6 +914,27 @@
+ + +

🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан. @@ -1065,6 +1090,10 @@ +

@@ -1194,7 +1223,7 @@ ru: { page_title: 'pdf2scan — онлайн сканирование PDF', logo_subtitle: 'Создание растровых pdf с эффектом сканирования из электронных pdf документов', - login_btn: 'Войти', + login_btn: 'Войти / Регистрация', login_via_tg: 'Через Telegram', login_via_yandex: 'Через Яндекс', login_via_vk: 'Через VK', @@ -1309,6 +1338,19 @@ paywall_pay_file: 'Оплатить файл', paywall_pay_sub: 'Подписка', paywall_month: 'мес', + paywall_renew_title: 'Продлить подписку', + paywall_renew_desc: 'Продлите доступ к большим файлам и пакетной обработке.', + sub_active_title: 'Подписка активна', + sub_until_label: 'до', + sub_renew: 'Продлить', + sub_perk_files: 'Большие файлы без лимита', + sub_perk_batch: 'Пакетная обработка', + sub_perk_priority: 'Приоритетная очередь', + paywall_autorenew: 'Продлевать автоматически каждый месяц', + sub_autorenew_on: 'Автопродление включено', + sub_autorenew_off: 'Автопродление выключено', + sub_autorenew_disable: 'Отключить', + sub_autorenew_enable: 'Включить', paywall_cancel: 'Отмена', paywall_need_login: 'Для подписки войдите в аккаунт', seo_title: 'Как работает pdf2scan', @@ -1323,7 +1365,7 @@ en: { page_title: 'pdf2scan — online PDF scan', logo_subtitle: 'Convert PDF to scanned document online', - login_btn: 'Login', + login_btn: 'Log in / Sign up', login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', login_via_vk: 'Via VK', @@ -1439,6 +1481,19 @@ paywall_pay_file: 'Pay for file', paywall_pay_sub: 'Subscription', paywall_month: 'mo', + paywall_renew_title: 'Renew subscription', + paywall_renew_desc: 'Extend access to large files and batch processing.', + sub_active_title: 'Subscription active', + sub_until_label: 'until', + sub_renew: 'Renew', + sub_perk_files: 'Large files, no limit', + sub_perk_batch: 'Batch processing', + sub_perk_priority: 'Priority queue', + paywall_autorenew: 'Renew automatically every month', + sub_autorenew_on: 'Auto-renewal on', + sub_autorenew_off: 'Auto-renewal off', + sub_autorenew_disable: 'Turn off', + sub_autorenew_enable: 'Turn on', paywall_cancel: 'Cancel', paywall_need_login: 'Log in to subscribe', seo_title: 'How pdf2scan works', @@ -1453,7 +1508,7 @@ de: { page_title: 'pdf2scan — Online-PDF-Scan', logo_subtitle: 'PDF online in gescannte Dokumente umwandeln', - login_btn: 'Anmelden', + login_btn: 'Anmelden / Registrieren', login_via_tg: 'Über Telegram', login_via_yandex: 'Über Yandex', login_via_vk: 'Über VK', @@ -1569,6 +1624,19 @@ paywall_pay_file: 'Datei bezahlen', paywall_pay_sub: 'Abo', paywall_month: 'Mon', + paywall_renew_title: 'Abo verlängern', + paywall_renew_desc: 'Verlängern Sie den Zugriff auf große Dateien und Stapelverarbeitung.', + sub_active_title: 'Abo aktiv', + sub_until_label: 'bis', + sub_renew: 'Verlängern', + sub_perk_files: 'Große Dateien ohne Limit', + sub_perk_batch: 'Stapelverarbeitung', + sub_perk_priority: 'Prioritäts-Warteschlange', + paywall_autorenew: 'Automatisch monatlich verlängern', + sub_autorenew_on: 'Automatische Verlängerung an', + sub_autorenew_off: 'Automatische Verlängerung aus', + sub_autorenew_disable: 'Ausschalten', + sub_autorenew_enable: 'Einschalten', paywall_cancel: 'Abbrechen', paywall_need_login: 'Zum Abonnieren anmelden', seo_title: 'So funktioniert pdf2scan', @@ -1583,7 +1651,7 @@ fr: { page_title: 'pdf2scan — scan PDF en ligne', logo_subtitle: 'Convertir des PDF en documents scannés en ligne', - login_btn: 'Connexion', + login_btn: 'Connexion / Inscription', login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', login_via_vk: 'Via VK', @@ -1699,6 +1767,19 @@ paywall_pay_file: 'Payer le fichier', paywall_pay_sub: 'Abonnement', paywall_month: 'mois', + paywall_renew_title: 'Renouveler votre abonnement', + paywall_renew_desc: 'Prolongez votre accès aux gros fichiers et au traitement par lots.', + sub_active_title: 'Abonnement actif', + sub_until_label: 'expire le', + sub_renew: 'Renouveler', + sub_perk_files: 'Gros fichiers sans limite', + sub_perk_batch: 'Traitement par lots', + sub_perk_priority: 'File prioritaire', + paywall_autorenew: 'Renouveler automatiquement chaque mois', + sub_autorenew_on: 'Renouvellement automatique activé', + sub_autorenew_off: 'Renouvellement automatique désactivé', + sub_autorenew_disable: 'Désactiver', + sub_autorenew_enable: 'Activer', paywall_cancel: 'Annuler', paywall_need_login: 'Connectez-vous pour vous abonner', seo_title: 'Comment fonctionne pdf2scan', @@ -1713,7 +1794,7 @@ it: { page_title: 'pdf2scan — scansione PDF online', logo_subtitle: 'Converti PDF in documenti scansionati online', - login_btn: 'Accedi', + login_btn: 'Accedi / Registrati', login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', login_via_vk: 'Via VK', @@ -1829,6 +1910,19 @@ paywall_pay_file: 'Paga file', paywall_pay_sub: 'Abbonamento', paywall_month: 'mese', + paywall_renew_title: 'Rinnova abbonamento', + paywall_renew_desc: 'Estendi accesso ai file grandi e alla elaborazione multipla.', + sub_active_title: 'Abbonamento attivo', + sub_until_label: 'fino al', + sub_renew: 'Rinnova', + sub_perk_files: 'File grandi senza limiti', + sub_perk_batch: 'Elaborazione multipla', + sub_perk_priority: 'Coda prioritaria', + paywall_autorenew: 'Rinnova automaticamente ogni mese', + sub_autorenew_on: 'Rinnovo automatico attivo', + sub_autorenew_off: 'Rinnovo automatico disattivato', + sub_autorenew_disable: 'Disattiva', + sub_autorenew_enable: 'Attiva', paywall_cancel: 'Annulla', paywall_need_login: 'Accedi per abbonarti', seo_title: 'Come funziona pdf2scan', @@ -1843,7 +1937,7 @@ es: { page_title: 'pdf2scan — escaneo PDF en línea', logo_subtitle: 'Convierte PDF en documentos escaneados en línea', - login_btn: 'Iniciar sesión', + login_btn: 'Entrar / Registro', login_via_tg: 'Con Telegram', login_via_yandex: 'Con Yandex', login_via_vk: 'Con VK', @@ -1959,6 +2053,19 @@ paywall_pay_file: 'Pagar archivo', paywall_pay_sub: 'Suscripción', paywall_month: 'mes', + paywall_renew_title: 'Renovar suscripción', + paywall_renew_desc: 'Amplía el acceso a archivos grandes y al procesamiento por lotes.', + sub_active_title: 'Suscripción activa', + sub_until_label: 'hasta el', + sub_renew: 'Renovar', + sub_perk_files: 'Archivos grandes sin límite', + sub_perk_batch: 'Procesamiento por lotes', + sub_perk_priority: 'Cola prioritaria', + paywall_autorenew: 'Renovar automáticamente cada mes', + sub_autorenew_on: 'Renovación automática activada', + sub_autorenew_off: 'Renovación automática desactivada', + sub_autorenew_disable: 'Desactivar', + sub_autorenew_enable: 'Activar', paywall_cancel: 'Cancelar', paywall_need_login: 'Inicia sesión para suscribirte', seo_title: 'Cómo funciona pdf2scan', @@ -1973,7 +2080,7 @@ pt: { page_title: 'pdf2scan — digitalização PDF online', logo_subtitle: 'Converta PDF em documentos digitalizados online', - login_btn: 'Entrar', + login_btn: 'Entrar / Registrar', login_via_tg: 'Via Telegram', login_via_yandex: 'Via Yandex', login_via_vk: 'Via VK', @@ -2089,6 +2196,19 @@ paywall_pay_file: 'Pagar arquivo', paywall_pay_sub: 'Assinatura', paywall_month: 'mês', + paywall_renew_title: 'Renovar assinatura', + paywall_renew_desc: 'Amplie o acesso a arquivos grandes e ao processamento em lote.', + sub_active_title: 'Assinatura ativa', + sub_until_label: 'até', + sub_renew: 'Renovar', + sub_perk_files: 'Arquivos grandes sem limite', + sub_perk_batch: 'Processamento em lote', + sub_perk_priority: 'Fila prioritária', + paywall_autorenew: 'Renovar automaticamente todo mês', + sub_autorenew_on: 'Renovação automática ativada', + sub_autorenew_off: 'Renovação automática desativada', + sub_autorenew_disable: 'Desativar', + sub_autorenew_enable: 'Ativar', paywall_cancel: 'Cancelar', paywall_need_login: 'Entre para assinar', seo_title: 'Como funciona o pdf2scan', @@ -2157,6 +2277,8 @@ }); // Re-render dynamic button state updateSubmitBtn(); + // Re-render subscription badge/card in the new language + if (subUntilTs) renderSubStatus({ active: subUntilTs > (Date.now() / 1000), until: subUntilTs }); } function setLang(lang) { @@ -2241,14 +2363,16 @@ paywallJob = jobId; paywallFilePrice = filePrice || 99; paywallSubPrice = subPrice || 299; - const isBatch = paywallMode === 'batch'; + const subOnly = (paywallMode === 'batch' || paywallMode === 'renew'); document.getElementById('paywall-file-price').textContent = paywallFilePrice; document.getElementById('paywall-sub-price').textContent = paywallSubPrice; - document.getElementById('paywall-file-btn').style.display = isBatch ? 'none' : ''; + document.getElementById('paywall-file-btn').style.display = subOnly ? 'none' : ''; const titleEl = document.querySelector('#paywall-card [data-i18n="paywall_title"]'); const descEl = document.querySelector('#paywall-card [data-i18n="paywall_desc"]'); - if (titleEl) titleEl.textContent = t(isBatch ? 'paywall_batch_title' : 'paywall_title'); - if (descEl) descEl.textContent = t(isBatch ? 'paywall_batch_desc' : 'paywall_desc'); + const tKey = paywallMode === 'renew' ? 'paywall_renew_title' : (paywallMode === 'batch' ? 'paywall_batch_title' : 'paywall_title'); + const dKey = paywallMode === 'renew' ? 'paywall_renew_desc' : (paywallMode === 'batch' ? 'paywall_batch_desc' : 'paywall_desc'); + if (titleEl) titleEl.textContent = t(tKey); + if (descEl) descEl.textContent = t(dKey); const emailEl = document.getElementById('paywall-email'); if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email; document.getElementById('paywall-file-btn').disabled = false; @@ -2269,7 +2393,7 @@ const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn'); fb.disabled = true; sb.disabled = true; // Если файл ещё не загружен (пейволл показан ДО загрузки) — грузим сейчас - if (!paywallJob && paywallMode !== 'batch' && selectedFile) { + if (!paywallJob && paywallMode === 'file' && selectedFile) { msg.textContent = t('pay_uploading'); try { const data = await uploadFileChunked(selectedFile, collectSettings(), null); @@ -2277,6 +2401,7 @@ } catch (e) { msg.textContent = (e && e.message) ? e.message : t('err_network'); fb.disabled = false; sb.disabled = false; return; } } const body = { purpose, email }; + if (purpose === 'sub') { const ar = document.getElementById('paywall-autorenew'); body.autorenew = ar ? ar.checked : false; } if (paywallJob) body.job_id = paywallJob; if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0; msg.textContent = t('pay_creating'); @@ -2454,6 +2579,85 @@ loginBtn.style.display = 'flex'; userInfo.style.display = 'none'; } + loadSubscription(); // обновляем бейдж/карточку подписки под текущий вход + } + + // ── Статус подписки (бейдж в шапке + карточка на главной) ───────────────── + let subUntilTs = 0, subAutorenew = false, subHasMethod = false; + const _DATE_LOC = { ru: 'ru-RU', en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', pt: 'pt-PT' }; + function _subDate(ts, opts) { + try { return new Date(ts * 1000).toLocaleDateString(_DATE_LOC[currentLang] || undefined, opts); } + catch (_) { return new Date(ts * 1000).toLocaleDateString(); } + } + async function loadSubscription() { + const badge = document.getElementById('sub-badge'); + const card = document.getElementById('sub-card'); + if (!tgUser && !sessionUser) { renderSubStatus({ active: false }); return; } + try { + const uid = tgUser ? (tgUser.user_id || tgUser.id || 0) : 0; + const r = await fetch('/api/subscription' + (uid ? ('?user_id=' + encodeURIComponent(uid)) : '')); + renderSubStatus(await r.json()); + } catch (_) { renderSubStatus({ active: false }); } + } + function renderSubStatus(d) { + const badge = document.getElementById('sub-badge'); + const card = document.getElementById('sub-card'); + subUntilTs = (d && d.active) ? (d.until || 0) : 0; + if (d && d.price) paywallSubPrice = d.price; + if (d && d.active && d.until) { + const shortD = _subDate(d.until, { day: '2-digit', month: '2-digit', year: 'numeric' }); + const longD = _subDate(d.until, { day: '2-digit', month: 'long', year: 'numeric' }); + if (badge) { + document.getElementById('sub-badge-text').textContent = t('sub_until_label') + ' ' + shortD; + badge.title = t('sub_active_title') + ' — ' + t('sub_until_label') + ' ' + longD; + badge.style.display = 'inline-flex'; + } + if (card) { + document.getElementById('sub-card-until').textContent = t('sub_until_label') + ' ' + longD; + card.style.display = 'block'; + } + // автопродление + subAutorenew = !!(d && d.autorenew); + subHasMethod = !!(d && d.has_method); + const arLabel = document.getElementById('sub-autorenew-label'); + const arToggle = document.getElementById('sub-autorenew-toggle'); + if (arLabel && arToggle) { + if (subAutorenew) { + arLabel.textContent = t('sub_autorenew_on'); + arToggle.textContent = t('sub_autorenew_disable'); + arToggle.style.display = 'inline'; + } else { + arLabel.textContent = t('sub_autorenew_off'); + if (subHasMethod) { arToggle.textContent = t('sub_autorenew_enable'); arToggle.style.display = 'inline'; } + else { arToggle.style.display = 'none'; } + } + } + } else { + if (badge) badge.style.display = 'none'; + if (card) card.style.display = 'none'; + } + } + async function toggleAutorenew() { + if (!tgUser && !sessionUser) return; + const body = { enable: !subAutorenew }; + if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0; + const arToggle = document.getElementById('sub-autorenew-toggle'); + if (arToggle) arToggle.style.pointerEvents = 'none'; + try { + const r = await fetch('/api/subscription/autorenew', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const d = await r.json(); + subAutorenew = !!d.autorenew; + } catch (e) {} + if (arToggle) arToggle.style.pointerEvents = ''; + loadSubscription(); + } + function scrollToSubCard() { + const card = document.getElementById('sub-card'); + if (card && card.style.display !== 'none') card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + function renewSub() { + if (!tgUser && !sessionUser) return; + showPaywall(null, paywallFilePrice, paywallSubPrice, 'renew'); } // ── Меню входа (Telegram / Яндекс) ──────────────────────────────────────── @@ -2577,6 +2781,13 @@ renderHeaderAuth(); if (tgUser) loadHistory(); if (!tgUser) loadMe(); // проверяем cookie-сессию (вход через Яндекс/VK) + // Маячок посещения (раз в сессию браузера) — считаем реальные заходы для админки + try { + if (!sessionStorage.getItem('p2s_hit')) { + sessionStorage.setItem('p2s_hit', '1'); + fetch('/api/hit', { method: 'POST', keepalive: true }).catch(function () {}); + } + } catch (_) {} // Возврат после OAuth: чистим ?login и показываем ошибку при неудаче (function () { const p = new URLSearchParams(location.search);