mirror of
https://gitverse.ru/kpa39l/md2vk.git
synced 2026-09-29 09:55:04 +00:00
Baseline md2vk: docs, audit log, docker 8420, openspec, deploy
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""Аудит-лог авторизации и запросов API (JSONL, ротация по дням).
|
||||
|
||||
Пишет строку JSON на каждый запрос /api/v1/*:
|
||||
ts, ip, method, path, api_key_prefix, user_id, status, success, latency_ms, error.
|
||||
|
||||
Параметры из env:
|
||||
- AUDIT_LOG_DIR — каталог для логов (по умолчанию "logs").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger("md2vk.audit")
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирует каждый запрос /api/v1/* в JSONL с ротацией по дням."""
|
||||
|
||||
def __init__(self, app, log_dir: str | None = None):
|
||||
super().__init__(app)
|
||||
self.log_dir = Path(log_dir or os.getenv("AUDIT_LOG_DIR", "logs"))
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = None
|
||||
self._fh_date: date | None = None
|
||||
|
||||
def _ensure_file(self) -> None:
|
||||
today = date.today()
|
||||
if self._fh is None or self._fh_date != today:
|
||||
if self._fh is not None:
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
path = self.log_dir / f"access.{today.isoformat()}.log"
|
||||
self._fh = open(path, "a", encoding="utf-8")
|
||||
self._fh_date = today
|
||||
|
||||
def _write(self, record: dict) -> None:
|
||||
try:
|
||||
self._ensure_file()
|
||||
self._fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
||||
self._fh.flush()
|
||||
except Exception:
|
||||
# Логгер не должен ронять API
|
||||
logger.exception("audit write failed")
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start = time.monotonic()
|
||||
response = None
|
||||
error = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
path = request.url.path
|
||||
if path.startswith("/api/v1"):
|
||||
try:
|
||||
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||
status = response.status_code if response is not None else 500
|
||||
auth = request.headers.get("authorization", "")
|
||||
|
||||
# api_key может быть в теле (POST) — пытаемся достать
|
||||
api_key_prefix = ""
|
||||
api_key_hash_short = ""
|
||||
user_id = None
|
||||
if auth.startswith("Bearer "):
|
||||
api_key_prefix = auth[len("Bearer "):][:12]
|
||||
elif request.method == "POST":
|
||||
api_key_prefix = self._api_key_from_body(request)
|
||||
if "md2vk_" in api_key_prefix:
|
||||
# вычислим короткий хэш для привязки к user (без хранения ключа)
|
||||
import hashlib
|
||||
api_key_hash_short = hashlib.sha256(
|
||||
api_key_prefix.encode()
|
||||
).hexdigest()[:12]
|
||||
|
||||
record = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"ip": (request.client.host if request.client else ""),
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"query": str(request.url.query) or "",
|
||||
"api_key_prefix": api_key_prefix,
|
||||
"api_key_hash_short": api_key_hash_short,
|
||||
"user_id": user_id,
|
||||
"status": status,
|
||||
"success": status < 400,
|
||||
"latency_ms": latency_ms,
|
||||
"error": error,
|
||||
}
|
||||
self._write(record)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("audit dispatch failed")
|
||||
|
||||
@staticmethod
|
||||
def _api_key_from_body(request: Request) -> str:
|
||||
"""Достаёт api_key из JSON-тела, не ломая повторное чтение."""
|
||||
try:
|
||||
# starlette кэширует _body — повторное чтение в роутере безопасно
|
||||
body = getattr(request, "_body", None)
|
||||
if body is None:
|
||||
body = request.body() if hasattr(request, "body") else b""
|
||||
if isinstance(body, bytes) and body:
|
||||
data = json.loads(body)
|
||||
key = data.get("api_key", "")
|
||||
return str(key)[:12]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FastAPI-зависимости: аутентификация по API-ключу."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_api_key(api_key: str) -> str:
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
|
||||
async def get_current_user_from_header(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Аутентификация по заголовку Authorization: Bearer <api_key>."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Authorization header. Use: Authorization: Bearer <api_key>",
|
||||
)
|
||||
|
||||
api_key = credentials.credentials
|
||||
if not api_key.startswith("md2vk_"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key format",
|
||||
)
|
||||
|
||||
api_key_hash = hash_api_key(api_key)
|
||||
result = await db.execute(
|
||||
select(User).where(User.api_key_hash == api_key_hash, User.is_active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_api_key(api_key: str, db: AsyncSession) -> User:
|
||||
"""Проверяет API-ключ из тела запроса. Используется в POST-эндпоинтах."""
|
||||
if not api_key.startswith("md2vk_"):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key format")
|
||||
|
||||
api_key_hash = hash_api_key(api_key)
|
||||
result = await db.execute(
|
||||
select(User).where(User.api_key_hash == api_key_hash, User.is_active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Pydantic-схемы для API-запросов и ответов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Аутентификация ────────────────────────────────────────────────────────
|
||||
|
||||
class ApiKeyRequest(BaseModel):
|
||||
api_key: str = Field(..., description="API-ключ пользователя")
|
||||
|
||||
|
||||
# ─── Аккаунты ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AccountCreateRequest(BaseModel):
|
||||
api_key: str
|
||||
vk_user_id: int = Field(..., description="VK owner_id (положительный — пользователь, отрицательный — сообщество)")
|
||||
display_name: str = Field(..., max_length=255, description="Отображаемое имя аккаунта")
|
||||
access_token: str = Field(..., description="VK OAuth-токен с правами wall")
|
||||
token_type: str = Field(default="user", pattern="^(user|group)$", description="user | group")
|
||||
|
||||
|
||||
class AccountResponse(BaseModel):
|
||||
id: int
|
||||
vk_user_id: int
|
||||
display_name: str
|
||||
token_type: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AccountListResponse(BaseModel):
|
||||
accounts: list[AccountResponse]
|
||||
|
||||
|
||||
# ─── Публикация ────────────────────────────────────────────────────────────
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
api_key: str
|
||||
vk_account_id: int = Field(..., description="ID VK-аккаунта из /api/v1/accounts")
|
||||
message_md: str = Field(..., min_length=1, description="Текст поста в Markdown")
|
||||
publish_date: Optional[datetime] = Field(None, description="ISO datetime для отложенной публикации")
|
||||
friends_only: bool = False
|
||||
attachments: Optional[str] = Field(None, description="VK-вложения через запятую (photo123_456, ...)")
|
||||
signed: Optional[bool] = Field(None, description="Подпись автора (для групп)")
|
||||
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
success: bool
|
||||
publication_id: int
|
||||
vk_post_id: Optional[int] = None
|
||||
vk_owner_id: Optional[int] = None
|
||||
url: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Конвертация ───────────────────────────────────────────────────────────
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
message_md: str = Field(..., min_length=1, description="Markdown-текст для конвертации")
|
||||
|
||||
|
||||
class FormatItemResponse(BaseModel):
|
||||
type: str
|
||||
offset: int
|
||||
length: int
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
text: str
|
||||
format_data: Optional[dict] = None
|
||||
|
||||
|
||||
# ─── Публикации (архив) ────────────────────────────────────────────────────
|
||||
|
||||
class PublicationFilterRequest(BaseModel):
|
||||
api_key: str
|
||||
vk_account_id: Optional[int] = None
|
||||
status: Optional[str] = Field(None, pattern="^(draft|scheduled|published|error)$")
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class PublicationItem(BaseModel):
|
||||
id: int
|
||||
vk_account_id: int
|
||||
status: str
|
||||
markdown_original: str
|
||||
vk_post_id: Optional[int] = None
|
||||
vk_owner_id: Optional[int] = None
|
||||
scheduled_at: Optional[datetime] = None
|
||||
published_at: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublicationListResponse(BaseModel):
|
||||
publications: list[PublicationItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Ошибки ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""API v1: эндпоинты публикации, конвертации, управления аккаунтами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, VkAccount, Publication
|
||||
from app.security import decrypt_token, encrypt_token
|
||||
from app.vk_client import VkClient, VkApiError
|
||||
from app.converters.markdown_to_vk import markdown_to_vk, format_data_json
|
||||
from app.api.schemas import (
|
||||
AccountCreateRequest,
|
||||
AccountResponse,
|
||||
AccountListResponse,
|
||||
PublishRequest,
|
||||
PublishResponse,
|
||||
ConvertRequest,
|
||||
ConvertResponse,
|
||||
PublicationFilterRequest,
|
||||
PublicationItem,
|
||||
PublicationListResponse,
|
||||
)
|
||||
from app.api.deps import get_current_user_from_header, get_user_by_api_key
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
||||
|
||||
|
||||
# ─── Health ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ─── Аккаунты ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AccountResponse)
|
||||
async def create_account(
|
||||
req: AccountCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Добавить VK-аккаунт. Проверяет токен через VK API перед сохранением."""
|
||||
user = await get_user_by_api_key(req.api_key, db)
|
||||
|
||||
# Проверяем токен через VK API
|
||||
vk = VkClient(req.access_token)
|
||||
try:
|
||||
is_valid, display_name = await vk.check_token()
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=f"VK token invalid: {display_name}")
|
||||
except VkApiError as e:
|
||||
raise HTTPException(status_code=400, detail=f"VK API error: {e}")
|
||||
finally:
|
||||
await vk.close()
|
||||
|
||||
# Шифруем токен перед сохранением
|
||||
encrypted = encrypt_token(req.access_token)
|
||||
name = req.display_name or display_name or f"VK-{req.vk_user_id}"
|
||||
|
||||
# Проверяем, нет ли уже такого VK-аккаунта у пользователя
|
||||
existing = await db.execute(
|
||||
select(VkAccount).where(
|
||||
VkAccount.user_id == user.id,
|
||||
VkAccount.vk_user_id == req.vk_user_id,
|
||||
VkAccount.is_active == True,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="This VK account is already registered")
|
||||
|
||||
account = VkAccount(
|
||||
user_id=user.id,
|
||||
vk_user_id=req.vk_user_id,
|
||||
display_name=name,
|
||||
access_token_enc=encrypted,
|
||||
token_type=req.token_type,
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
|
||||
return AccountResponse(
|
||||
id=account.id,
|
||||
vk_user_id=account.vk_user_id,
|
||||
display_name=account.display_name,
|
||||
token_type=account.token_type,
|
||||
is_active=account.is_active,
|
||||
created_at=account.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=AccountListResponse)
|
||||
async def list_accounts(
|
||||
user: User = Depends(get_current_user_from_header),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Список VK-аккаунтов текущего пользователя."""
|
||||
result = await db.execute(
|
||||
select(VkAccount).where(VkAccount.user_id == user.id, VkAccount.is_active == True)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
return AccountListResponse(
|
||||
accounts=[
|
||||
AccountResponse(
|
||||
id=a.id,
|
||||
vk_user_id=a.vk_user_id,
|
||||
display_name=a.display_name,
|
||||
token_type=a.token_type,
|
||||
is_active=a.is_active,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}", status_code=204)
|
||||
async def delete_account(
|
||||
account_id: int,
|
||||
user: User = Depends(get_current_user_from_header),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Удалить VK-аккаунт (soft delete)."""
|
||||
result = await db.execute(
|
||||
select(VkAccount).where(
|
||||
VkAccount.id == account_id,
|
||||
VkAccount.user_id == user.id,
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account.is_active = False
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ─── Публикация ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/publish", response_model=PublishResponse)
|
||||
async def publish(
|
||||
req: PublishRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Опубликовать пост на стене VK."""
|
||||
# Аутентификация по api_key из тела запроса
|
||||
user = await get_user_by_api_key(req.api_key, db)
|
||||
|
||||
# Получаем VK-аккаунт
|
||||
result = await db.execute(
|
||||
select(VkAccount).where(
|
||||
VkAccount.id == req.vk_account_id,
|
||||
VkAccount.user_id == user.id,
|
||||
VkAccount.is_active == True,
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="VK account not found")
|
||||
|
||||
# Расшифровываем токен (только в памяти!)
|
||||
token = decrypt_token(account.access_token_enc)
|
||||
if token is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to decrypt VK token (key mismatch?)")
|
||||
|
||||
# Конвертируем Markdown
|
||||
chunks = markdown_to_vk(req.message_md)
|
||||
if not chunks or not chunks[0].text.strip():
|
||||
raise HTTPException(status_code=400, detail="Empty message after markdown conversion")
|
||||
|
||||
chunk = chunks[0]
|
||||
fd_json = format_data_json(chunk.items) if chunk.items else None
|
||||
|
||||
# Создаём запись о публикации
|
||||
publication = Publication(
|
||||
vk_account_id=account.id,
|
||||
status="draft",
|
||||
markdown_original=req.message_md,
|
||||
vk_text=chunk.text,
|
||||
vk_format_data=fd_json,
|
||||
scheduled_at=req.publish_date,
|
||||
)
|
||||
db.add(publication)
|
||||
await db.flush()
|
||||
|
||||
# Если отложенная — сохраняем и выходим
|
||||
if req.publish_date:
|
||||
publication.status = "scheduled"
|
||||
await db.flush()
|
||||
return PublishResponse(
|
||||
success=True,
|
||||
publication_id=publication.id,
|
||||
)
|
||||
|
||||
# Публикуем через VK API
|
||||
vk = VkClient(token)
|
||||
try:
|
||||
result = await vk.wall_post(
|
||||
message=chunk.text,
|
||||
owner_id=account.vk_user_id if account.token_type == "group" else None,
|
||||
from_group=(account.token_type == "group"),
|
||||
friends_only=req.friends_only,
|
||||
publish_date=int(req.publish_date.timestamp()) if req.publish_date else None,
|
||||
attachments=req.attachments,
|
||||
signed=req.signed,
|
||||
format_data=chunk.items if chunk.items else None,
|
||||
)
|
||||
|
||||
post_id = result.get("post_id")
|
||||
owner_id = result.get("owner_id") or account.vk_user_id
|
||||
|
||||
publication.status = "published"
|
||||
publication.vk_post_id = post_id
|
||||
publication.vk_owner_id = owner_id
|
||||
publication.published_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||
|
||||
return PublishResponse(
|
||||
success=True,
|
||||
publication_id=publication.id,
|
||||
vk_post_id=post_id,
|
||||
vk_owner_id=owner_id,
|
||||
url=url,
|
||||
)
|
||||
|
||||
except VkApiError as e:
|
||||
publication.status = "error"
|
||||
publication.error_message = str(e)
|
||||
await db.flush()
|
||||
return PublishResponse(
|
||||
success=False,
|
||||
publication_id=publication.id,
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
await vk.close()
|
||||
|
||||
|
||||
@router.post("/convert", response_model=ConvertResponse)
|
||||
async def convert(req: ConvertRequest):
|
||||
"""Конвертировать Markdown в VK format_data (без публикации)."""
|
||||
chunks = markdown_to_vk(req.message_md)
|
||||
if not chunks:
|
||||
return ConvertResponse(text="", format_data={"version": 1, "items": []})
|
||||
|
||||
chunk = chunks[0]
|
||||
items = []
|
||||
for i in chunk.items:
|
||||
items.append({"type": i.type, "offset": i.offset, "length": i.length, "url": i.url})
|
||||
|
||||
return ConvertResponse(
|
||||
text=chunk.text,
|
||||
format_data={"version": 1, "items": items},
|
||||
)
|
||||
|
||||
|
||||
# ─── Архив публикаций ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/publications", response_model=PublicationListResponse)
|
||||
async def list_publications(
|
||||
req: PublicationFilterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Архив публикаций с фильтрацией."""
|
||||
user = await get_user_by_api_key(req.api_key, db)
|
||||
|
||||
# Базовый запрос — только публикации пользователя
|
||||
base_filter = VkAccount.user_id == user.id
|
||||
|
||||
query = (
|
||||
select(Publication)
|
||||
.join(VkAccount)
|
||||
.where(base_filter)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count(Publication.id))
|
||||
.join(VkAccount)
|
||||
.where(base_filter)
|
||||
)
|
||||
|
||||
if req.vk_account_id is not None:
|
||||
query = query.where(Publication.vk_account_id == req.vk_account_id)
|
||||
count_query = count_query.where(Publication.vk_account_id == req.vk_account_id)
|
||||
if req.status is not None:
|
||||
query = query.where(Publication.status == req.status)
|
||||
count_query = count_query.where(Publication.status == req.status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(Publication.created_at.desc()).offset(req.offset).limit(req.limit)
|
||||
result = await db.execute(query)
|
||||
publications = result.scalars().all()
|
||||
|
||||
return PublicationListResponse(
|
||||
publications=[
|
||||
PublicationItem(
|
||||
id=p.id,
|
||||
vk_account_id=p.vk_account_id,
|
||||
status=p.status,
|
||||
markdown_original=p.markdown_original,
|
||||
vk_post_id=p.vk_post_id,
|
||||
vk_owner_id=p.vk_owner_id,
|
||||
scheduled_at=p.scheduled_at,
|
||||
published_at=p.published_at,
|
||||
error_message=p.error_message,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
for p in publications
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Конфигурация md2vk из переменных окружения."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# HTTP
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# Файл с Fernet-ключом (Docker secret / файл на диске)
|
||||
token_encryption_key_file: str = "/run/secrets/token_encryption_key"
|
||||
|
||||
# База данных
|
||||
database_url: str = "sqlite+aiosqlite:///data/md2vk.db"
|
||||
|
||||
# VK API
|
||||
vk_api_version: str = "5.199"
|
||||
|
||||
# Rate limiting (запросов в минуту на VK-аккаунт)
|
||||
rate_limit_per_minute: int = 10
|
||||
|
||||
model_config = {"env_file": ".env", "env_prefix": ""}
|
||||
|
||||
@property
|
||||
def fernet_key(self) -> bytes:
|
||||
"""Читает и возвращает Fernet-ключ из файла."""
|
||||
key_path = Path(self.token_encryption_key_file)
|
||||
if not key_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Файл с Fernet-ключом не найден: {key_path}. "
|
||||
f"Сгенерируйте: python3 -c \"from cryptography.fernet import Fernet; "
|
||||
f"print(Fernet.generate_key().decode())\" > {key_path}"
|
||||
)
|
||||
return key_path.read_bytes().strip()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Конвертер Markdown → VK format_data.
|
||||
|
||||
Поддерживает: **жирный**, *курсив*, `код`, [ссылки], #заголовки, > цитаты, ```блоки кода```, ---.
|
||||
Разбивает длинные тексты на чанки (VK лимит ~4096 символов).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormatItem:
|
||||
type: str # bold | italic | link | inline_code
|
||||
offset: int
|
||||
length: int
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
text: str
|
||||
items: list[FormatItem] = field(default_factory=list)
|
||||
|
||||
|
||||
# ─── AST-узлы для промежуточного представления ───────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TextNode:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoldNode:
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItalicNode:
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoldItalicNode:
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeNode:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkNode:
|
||||
text: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderNode:
|
||||
level: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockquoteNode:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeBlockNode:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HrNode:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphNode:
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentNode:
|
||||
children: list = field(default_factory=list)
|
||||
|
||||
|
||||
# ─── Парсер Markdown (блочный + строчный) ─────────────────────────────────
|
||||
|
||||
|
||||
def parse_block(text: str) -> list:
|
||||
"""Разбивает текст на блочные элементы."""
|
||||
lines = text.split("\n")
|
||||
blocks = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Горизонтальная линия
|
||||
if re.match(r"^-{3,}$", line.strip()):
|
||||
blocks.append(HrNode())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Заголовок
|
||||
hm = re.match(r"^(#{1,6})\s+(.+)$", line)
|
||||
if hm:
|
||||
blocks.append(HeaderNode(level=len(hm.group(1)), text=hm.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Цитата
|
||||
if line.startswith("> "):
|
||||
quote_lines = []
|
||||
while i < len(lines) and lines[i].startswith("> "):
|
||||
quote_lines.append(lines[i][2:])
|
||||
i += 1
|
||||
blocks.append(BlockquoteNode(text="\n".join(quote_lines)))
|
||||
continue
|
||||
|
||||
# Блок кода
|
||||
if line.startswith("```"):
|
||||
code_lines = []
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].startswith("```"):
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # пропускаем закрывающие ```
|
||||
blocks.append(CodeBlockNode(text="\n".join(code_lines)))
|
||||
continue
|
||||
|
||||
# Пустая строка — разделитель параграфов
|
||||
if line.strip() == "":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Обычный параграф
|
||||
para_lines = []
|
||||
while i < len(lines) and lines[i].strip() != "" and not lines[i].startswith("```") and not re.match(r"^-{3,}$", lines[i].strip()):
|
||||
# Проверка на заголовок внутри — не разрываем параграф
|
||||
if re.match(r"^#{1,6}\s+", lines[i]) and len(para_lines) > 0:
|
||||
break
|
||||
para_lines.append(lines[i])
|
||||
i += 1
|
||||
blocks.append(ParagraphNode(children=parse_inline("\n".join(para_lines))))
|
||||
# Не инкрементим i, т.к. цикл while уже продвинул
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def parse_inline(text: str) -> list:
|
||||
"""Парсит строчные элементы: **жирный**, *курсив*, `код`, [ссылки], ***жирный+курсив***."""
|
||||
result = []
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
# ***жирный+курсив***
|
||||
m = re.match(r"\*\*\*(.+?)\*\*\*", text[pos:])
|
||||
if m:
|
||||
result.append(BoldItalicNode(children=[TextNode(text=m.group(1))]))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# **жирный**
|
||||
m = re.match(r"\*\*(.+?)\*\*", text[pos:])
|
||||
if m:
|
||||
result.append(BoldNode(children=[TextNode(text=m.group(1))]))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# __жирный__
|
||||
m = re.match(r"__(.+?)__", text[pos:])
|
||||
if m:
|
||||
result.append(BoldNode(children=[TextNode(text=m.group(1))]))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# *курсив*
|
||||
m = re.match(r"\*(.+?)\*", text[pos:])
|
||||
if m:
|
||||
# Убедимся, что это не **
|
||||
if not text[pos:].startswith("**"):
|
||||
result.append(ItalicNode(children=[TextNode(text=m.group(1))]))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# _курсив_
|
||||
m = re.match(r"_(.+?)_", text[pos:])
|
||||
if m:
|
||||
if not text[pos:].startswith("__"):
|
||||
result.append(ItalicNode(children=[TextNode(text=m.group(1))]))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# `код`
|
||||
m = re.match(r"`([^`]+)`", text[pos:])
|
||||
if m:
|
||||
result.append(CodeNode(text=m.group(1)))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# [ссылка](url)
|
||||
m = re.match(r"\[([^\]]+)\]\(([^)]+)\)", text[pos:])
|
||||
if m:
|
||||
result.append(LinkNode(text=m.group(1), url=m.group(2)))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# Обычный текст
|
||||
m = re.match(r"[^*_`\[<]+", text[pos:])
|
||||
if m:
|
||||
result.append(TextNode(text=m.group(0)))
|
||||
pos += len(m.group(0))
|
||||
continue
|
||||
|
||||
# Одиночный символ (если не подошло ни одно правило)
|
||||
result.append(TextNode(text=text[pos]))
|
||||
pos += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── Генерация VK-формата ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _render_node(node, plain_text: list[str], format_items: list[FormatItem], base_offset: int) -> int:
|
||||
"""Рендерит AST-узел в plain_text и format_items. Возвращает новый offset."""
|
||||
if isinstance(node, TextNode):
|
||||
plain_text.append(node.text)
|
||||
return base_offset + len(node.text)
|
||||
|
||||
elif isinstance(node, BoldNode):
|
||||
inner_start = base_offset
|
||||
offset = inner_start
|
||||
for child in node.children:
|
||||
offset = _render_node(child, plain_text, format_items, offset)
|
||||
if offset > inner_start:
|
||||
format_items.append(FormatItem(type="bold", offset=inner_start, length=offset - inner_start))
|
||||
return offset
|
||||
|
||||
elif isinstance(node, ItalicNode):
|
||||
inner_start = base_offset
|
||||
offset = inner_start
|
||||
for child in node.children:
|
||||
offset = _render_node(child, plain_text, format_items, offset)
|
||||
if offset > inner_start:
|
||||
format_items.append(FormatItem(type="italic", offset=inner_start, length=offset - inner_start))
|
||||
return offset
|
||||
|
||||
elif isinstance(node, BoldItalicNode):
|
||||
inner_start = base_offset
|
||||
offset = inner_start
|
||||
for child in node.children:
|
||||
offset = _render_node(child, plain_text, format_items, offset)
|
||||
if offset > inner_start:
|
||||
format_items.append(FormatItem(type="bold", offset=inner_start, length=offset - inner_start))
|
||||
format_items.append(FormatItem(type="italic", offset=inner_start, length=offset - inner_start))
|
||||
return offset
|
||||
|
||||
elif isinstance(node, CodeNode):
|
||||
plain_text.append(node.text)
|
||||
length = len(node.text)
|
||||
format_items.append(FormatItem(type="inline_code", offset=base_offset, length=length))
|
||||
return base_offset + length
|
||||
|
||||
elif isinstance(node, LinkNode):
|
||||
offset = base_offset
|
||||
for child in parse_inline(node.text):
|
||||
offset = _render_node(child, plain_text, format_items, offset)
|
||||
length = offset - base_offset
|
||||
format_items.append(FormatItem(type="link", offset=base_offset, length=length, url=node.url))
|
||||
return offset
|
||||
|
||||
elif isinstance(node, HeaderNode):
|
||||
# Заголовки → жирный + uppercase
|
||||
text = node.text.upper()
|
||||
plain_text.append(text)
|
||||
format_items.append(FormatItem(type="bold", offset=base_offset, length=len(text)))
|
||||
return base_offset + len(text)
|
||||
|
||||
elif isinstance(node, BlockquoteNode):
|
||||
# Цитата → italic
|
||||
text = node.text
|
||||
plain_text.append(text)
|
||||
format_items.append(FormatItem(type="italic", offset=base_offset, length=len(text)))
|
||||
return base_offset + len(text)
|
||||
|
||||
elif isinstance(node, CodeBlockNode):
|
||||
text = node.text
|
||||
plain_text.append(text)
|
||||
return base_offset + len(text)
|
||||
|
||||
elif isinstance(node, HrNode):
|
||||
plain_text.append("───")
|
||||
return base_offset + 3
|
||||
|
||||
elif isinstance(node, ParagraphNode):
|
||||
offset = base_offset
|
||||
for child in node.children:
|
||||
offset = _render_node(child, plain_text, format_items, offset)
|
||||
return offset
|
||||
|
||||
return base_offset
|
||||
|
||||
|
||||
def render_document(blocks: list) -> tuple[str, list[FormatItem]]:
|
||||
"""Рендерит список блоков в плоский текст + format_items."""
|
||||
plain_text: list[str] = []
|
||||
format_items: list[FormatItem] = []
|
||||
offset = 0
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
if i > 0:
|
||||
plain_text.append("\n\n")
|
||||
offset += 2
|
||||
offset = _render_node(block, plain_text, format_items, offset)
|
||||
|
||||
return "".join(plain_text), format_items
|
||||
|
||||
|
||||
# ─── Разбиение на чанки ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _vk_char_len(text: str) -> int:
|
||||
"""VK считает @ за 2 символа. Учитываем это при подсчёте длины."""
|
||||
count = 0
|
||||
for ch in text:
|
||||
count += 2 if ch == "@" else 1
|
||||
return count
|
||||
|
||||
|
||||
def _split_into_chunks(text: str, items: list[FormatItem], chunk_size: int = 4096) -> list[Chunk]:
|
||||
"""Разбивает текст на чанки, корректируя format_items на границах."""
|
||||
if not text:
|
||||
return [Chunk(text="", items=[])]
|
||||
|
||||
# Определяем границы разбиения по \n\n (абзацы)
|
||||
# Если текст влезает целиком — один чанк
|
||||
if _vk_char_len(text) <= chunk_size:
|
||||
return [Chunk(text=text, items=_adjust_items(items, 0, len(text)))]
|
||||
|
||||
chunks: list[Chunk] = []
|
||||
start = 0
|
||||
|
||||
while start < len(text):
|
||||
# Ищем границу: \n\n в пределах chunk_size
|
||||
end = start + int(chunk_size * 0.9) # 90% от лимита — запас
|
||||
if end >= len(text):
|
||||
end = len(text)
|
||||
|
||||
# Ищем \n\n назад от end
|
||||
split_pos = text.rfind("\n\n", start, end)
|
||||
if split_pos == -1 or split_pos <= start:
|
||||
# Если нет \n\n — ищем последний пробел
|
||||
split_pos = text.rfind(" ", start, end)
|
||||
if split_pos == -1 or split_pos <= start:
|
||||
split_pos = end
|
||||
|
||||
chunk_text = text[start:split_pos].strip()
|
||||
if chunk_text:
|
||||
chunk_items = _adjust_items(items, start, split_pos)
|
||||
chunks.append(Chunk(text=chunk_text, items=chunk_items))
|
||||
|
||||
start = split_pos + 1 # пропускаем разделитель
|
||||
|
||||
return chunks if chunks else [Chunk(text=text, items=[])]
|
||||
|
||||
|
||||
def _adjust_items(items: list[FormatItem], start: int, end: int) -> list[FormatItem]:
|
||||
"""Обрезает format_items для диапазона [start, end) и сдвигает offset."""
|
||||
result = []
|
||||
for item in items:
|
||||
item_end = item.offset + item.length
|
||||
# Проверяем пересечение
|
||||
if item_end <= start or item.offset >= end:
|
||||
continue
|
||||
new_offset = max(item.offset, start) - start
|
||||
new_length = min(item_end, end) - max(item.offset, start)
|
||||
result.append(FormatItem(
|
||||
type=item.type,
|
||||
offset=new_offset,
|
||||
length=new_length,
|
||||
url=item.url,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
# ─── Публичный API ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def markdown_to_vk(text: str, chunk_size: int = 4096) -> list[Chunk]:
|
||||
"""Конвертирует Markdown в список чанков, готовых к отправке в VK API.
|
||||
|
||||
Каждый чанк содержит:
|
||||
- text: plain text для поля message
|
||||
- items: список FormatItem для format_data
|
||||
|
||||
VK принимает format_data через поле format_data в wall.post,
|
||||
которое должно быть JSON-строкой вида:
|
||||
{"version": 1, "items": [{"type": "bold", "offset": 0, "length": 5}]}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return [Chunk(text="", items=[])]
|
||||
|
||||
blocks = parse_block(text)
|
||||
plain_text, items = render_document(blocks)
|
||||
return _split_into_chunks(plain_text, items, chunk_size)
|
||||
|
||||
|
||||
def format_data_json(items: list[FormatItem]) -> str:
|
||||
"""Сериализует format_items в JSON для VK API."""
|
||||
import json
|
||||
vk_items = []
|
||||
for item in items:
|
||||
d = {"type": item.type, "offset": item.offset, "length": item.length}
|
||||
if item.url:
|
||||
d["url"] = item.url
|
||||
vk_items.append(d)
|
||||
return json.dumps({"version": 1, "items": vk_items}, ensure_ascii=False)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""База данных: async engine, сессии, инициализация."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Создаём каталог для БД, если SQLite
|
||||
_db_path = settings.database_url.replace("sqlite+aiosqlite:///", "")
|
||||
if _db_path != settings.database_url:
|
||||
Path(_db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI-зависимость: сессия БД."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Создаёт таблицы при старте."""
|
||||
from app.models import Base # noqa: F401 — импорт моделей для регистрации
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
"""md2vk — FastAPI приложение."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.database import init_db
|
||||
from app.api.v1 import router as api_v1_router
|
||||
from app.api.audit import AuditMiddleware
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("md2vk")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Инициализация при старте."""
|
||||
logger.info("Initializing database...")
|
||||
await init_db()
|
||||
logger.info("Database ready. Starting md2vk...")
|
||||
yield
|
||||
logger.info("Shutting down...")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="md2vk",
|
||||
description="Сервис публикации Markdown на стене VK",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Аудит-лог авторизации/запросов API (JSONL, ротация по дням)
|
||||
app.add_middleware(AuditMiddleware)
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled error: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Internal server error: {str(exc)}"},
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""ORM-модели: User, VkAccount, Publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, unique=True)
|
||||
api_key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
api_key_prefix: Mapped[str] = mapped_column(String(12), nullable=False, comment="Первые ~10 символов ключа для идентификации")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
vk_accounts: Mapped[list[VkAccount]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class VkAccount(Base):
|
||||
__tablename__ = "vk_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
vk_user_id: Mapped[int] = mapped_column(Integer, nullable=False, comment="VK owner_id (положительный — пользователь, отрицательный — сообщество)")
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="Отображаемое имя (например, 'Моя стена')")
|
||||
access_token_enc: Mapped[str] = mapped_column(Text, nullable=False, comment="Зашифрованный VK-токен")
|
||||
token_type: Mapped[str] = mapped_column(String(10), default="user", nullable=False, comment="user | group")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
expires_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True)
|
||||
last_used_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="vk_accounts")
|
||||
publications: Mapped[list[Publication]] = relationship(back_populates="vk_account", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Publication(Base):
|
||||
__tablename__ = "publications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
vk_account_id: Mapped[int] = mapped_column(ForeignKey("vk_accounts.id"), nullable=False, index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="draft",
|
||||
nullable=False,
|
||||
comment="draft | scheduled | published | error",
|
||||
)
|
||||
|
||||
# Входные данные
|
||||
markdown_original: Mapped[str] = mapped_column(Text, nullable=False, comment="Исходный Markdown")
|
||||
|
||||
# Результат конвертации
|
||||
vk_text: Mapped[str] = mapped_column(Text, nullable=False, comment="Текст для VK")
|
||||
vk_format_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="JSON format_data")
|
||||
|
||||
# Результат публикации
|
||||
vk_post_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
vk_owner_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
attachments: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="JSON attachments")
|
||||
|
||||
# Отложенная публикация
|
||||
scheduled_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True)
|
||||
published_at: Mapped[Optional[datetime.datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Ошибки
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
vk_account: Mapped[VkAccount] = relationship(back_populates="publications")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Безопасность: шифрование токенов, генерация API-ключей."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_fernet() -> Fernet:
|
||||
"""Создаёт Fernet-инстанс из ключа в settings."""
|
||||
return Fernet(settings.fernet_key)
|
||||
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
"""Шифрует VK-токен. Возвращает base64-строку."""
|
||||
f = get_fernet()
|
||||
return f.encrypt(token.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_token(encrypted: str) -> Optional[str]:
|
||||
"""Расшифровывает VK-токен. Возвращает None при ошибке."""
|
||||
try:
|
||||
f = get_fernet()
|
||||
return f.decrypt(encrypted.encode()).decode()
|
||||
except (InvalidToken, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def generate_api_key() -> tuple[str, str]:
|
||||
"""Генерирует пару (api_key, api_key_hash).
|
||||
|
||||
api_key — то, что отдаётся пользователю (md2vk_xxx...)
|
||||
api_key_hash — SHA-256 хеш, хранится в БД.
|
||||
"""
|
||||
raw = secrets.token_hex(32)
|
||||
api_key = f"md2vk_{raw}"
|
||||
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
return api_key, api_key_hash
|
||||
|
||||
|
||||
def verify_api_key(api_key: str, api_key_hash: str) -> bool:
|
||||
"""Проверяет API-ключ по хранимому хешу (constant-time)."""
|
||||
computed = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
return hmac.compare_digest(computed, api_key_hash)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""VK API клиент — вызов wall.post с format_data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.converters.markdown_to_vk import FormatItem, format_data_json
|
||||
|
||||
|
||||
class VkApiError(Exception):
|
||||
"""Ошибка VK API. Содержит код ошибки и описание."""
|
||||
def __init__(self, error_code: int, error_msg: str):
|
||||
self.error_code = error_code
|
||||
self.error_msg = error_msg
|
||||
super().__init__(f"VK API error #{error_code}: {error_msg}")
|
||||
|
||||
|
||||
class VkClient:
|
||||
"""HTTP-клиент для VK API."""
|
||||
|
||||
BASE_URL = "https://api.vk.com/method"
|
||||
|
||||
def __init__(self, access_token: str):
|
||||
self.access_token = access_token
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
async def wall_post(
|
||||
self,
|
||||
message: str,
|
||||
owner_id: Optional[int] = None,
|
||||
from_group: bool = False,
|
||||
friends_only: bool = False,
|
||||
publish_date: Optional[int] = None,
|
||||
attachments: Optional[str] = None,
|
||||
signed: Optional[bool] = None,
|
||||
format_data: Optional[list[FormatItem]] = None,
|
||||
) -> dict:
|
||||
"""Публикует запись на стене через wall.post.
|
||||
|
||||
Возвращает ответ VK API с полями post_id, owner_id.
|
||||
"""
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"v": settings.vk_api_version,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if owner_id is not None:
|
||||
params["owner_id"] = owner_id
|
||||
if from_group:
|
||||
params["from_group"] = 1
|
||||
if friends_only:
|
||||
params["friends_only"] = 1
|
||||
if publish_date is not None:
|
||||
params["publish_date"] = publish_date
|
||||
if attachments is not None:
|
||||
params["attachments"] = attachments
|
||||
if signed is not None:
|
||||
params["signed"] = 1 if signed else 0
|
||||
if format_data:
|
||||
params["format_data"] = format_data_json(format_data)
|
||||
|
||||
response = await self._client.post(f"{self.BASE_URL}/wall.post", data=params)
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
raise VkApiError(
|
||||
error_code=err.get("error_code", 0),
|
||||
error_msg=err.get("error_msg", "Unknown error"),
|
||||
)
|
||||
|
||||
return data.get("response", {})
|
||||
|
||||
async def users_get(self, user_ids: str) -> list[dict]:
|
||||
"""Получает информацию о пользователе (для проверки токена/имени)."""
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"v": settings.vk_api_version,
|
||||
"user_ids": user_ids,
|
||||
}
|
||||
response = await self._client.post(f"{self.BASE_URL}/users.get", data=params)
|
||||
data = response.json()
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
raise VkApiError(
|
||||
error_code=err.get("error_code", 0),
|
||||
error_msg=err.get("error_msg", "Unknown error"),
|
||||
)
|
||||
return data.get("response", [])
|
||||
|
||||
async def check_token(self) -> tuple[bool, str]:
|
||||
"""Проверяет валидность токена. Возвращает (is_valid, display_name)."""
|
||||
try:
|
||||
users = await self.users_get("")
|
||||
if users:
|
||||
name = f"{users[0].get('first_name', '')} {users[0].get('last_name', '')}".strip()
|
||||
return True, name or "Unknown"
|
||||
return False, "Token invalid"
|
||||
except VkApiError:
|
||||
return False, "Token invalid or expired"
|
||||
Reference in New Issue
Block a user