Initial commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Security
|
||||
SECRET_KEY=change-me-in-production
|
||||
|
||||
# Admin access control (comma-separated IPs, empty = allow all)
|
||||
# ALLOWED_ADMIN_IPS=192.168.1.1,10.0.0.5
|
||||
|
||||
# Database (optional, defaults to sqlite)
|
||||
# DATABASE_URL=sqlite:///data/tnproxy.db
|
||||
|
||||
# Logging
|
||||
# LOG_DIR=/app/logs
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_ROTATE_MAX_BYTES=10485760
|
||||
# LOG_ROTATE_BACKUP_COUNT=5
|
||||
|
||||
# Email notifications (SMTP)
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASSWORD=your-app-password
|
||||
# SMTP_FROM=your-email@gmail.com
|
||||
# SMTP_TO=notify@example.com
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.db
|
||||
data/
|
||||
logs/
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY config.py server.py models.py database.py ./
|
||||
COPY templates/ ./templates/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# TNProxy - Telegram Notification Proxy
|
||||
|
||||
Прокси-сервер для отправки сообщений в Telegram через внешний сервер с авторизацией приложений.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Множество приложений с индивидуальными токенами
|
||||
- Каждое приложение привязывается к своему боту Telegram
|
||||
- Веб-интерфейс для управления приложениями
|
||||
- Docker для простого развертывания
|
||||
|
||||
## Быстрый старт (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Откройте `http://localhost:8880/adtn` для управления приложениями.
|
||||
|
||||
## Использование
|
||||
|
||||
### 1. Создайте приложение в веб-интерфейсе
|
||||
|
||||
Перейдите на `/adtn`, нажмите "Добавить приложение", введите:
|
||||
- Название
|
||||
- Telegram Bot Token (получить у @BotFather)
|
||||
- Telegram Chat ID (ваш ID или ID канала)
|
||||
|
||||
Получите токен приложения.
|
||||
|
||||
### 2. Отправка сообщения
|
||||
|
||||
```http
|
||||
POST /send
|
||||
X-App-Token: <токен_приложения>
|
||||
Content-Type: application/json
|
||||
|
||||
{"text": "Hello, World!"}
|
||||
```
|
||||
|
||||
### Пример PowerShell:
|
||||
|
||||
```powershell
|
||||
$body = @{text = "Alert!"} | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "http://localhost:8080/send" -Method Post -Body $body -Headers @{
|
||||
"Content-Type" = "application/json"
|
||||
"X-App-Token" = "YOUR_APP_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | /send | Отправить сообщение |
|
||||
| GET | /health | Проверка здоровья |
|
||||
| GET | /adtn | Веб-интерфейс |
|
||||
| POST | /adtn/applications | Создать приложение |
|
||||
| DELETE | /adtn/applications/{id} | Удалить приложение |
|
||||
| POST | /adtn/applications/{id}/toggle | Включить/отключить |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── config.py # Конфигурация
|
||||
├── server.py # FastAPI сервер
|
||||
├── models.py # Модели SQLAlchemy
|
||||
├── database.py # База данных
|
||||
├── requirements.txt # Python зависимости
|
||||
├── Dockerfile # Docker образ
|
||||
├── docker-compose.yml # Docker Compose
|
||||
├── templates/ # HTML шаблоны
|
||||
├── clients/ # Примеры клиентов
|
||||
└── data/ # SQLite БД (создается при запуске)
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
const char* SERVER_URL = "http://192.168.1.100:8880/send";
|
||||
const char* APP_TOKEN = ""; // Set your application token here
|
||||
|
||||
const char* WIFI_SSID = "YourWiFiSSID";
|
||||
const char* WIFI_PASSWORD = "YourWiFiPassword";
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
Serial.println("\nWiFi connected");
|
||||
}
|
||||
|
||||
void sendTelegramMessage(const char* message) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
HTTPClient http;
|
||||
http.begin(SERVER_URL);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
|
||||
if (strlen(APP_TOKEN) > 0) {
|
||||
http.addHeader("X-App-Token", APP_TOKEN);
|
||||
}
|
||||
|
||||
String payload = "{\"text\":\"" + String(message) + "\"}";
|
||||
int httpResponseCode = http.POST(payload);
|
||||
|
||||
if (httpResponseCode > 0) {
|
||||
String response = http.getString();
|
||||
Serial.println("HTTP Response: " + String(httpResponseCode));
|
||||
Serial.println(response);
|
||||
} else {
|
||||
Serial.println("Error: " + String(httpResponseCode));
|
||||
}
|
||||
|
||||
http.end();
|
||||
} else {
|
||||
Serial.println("WiFi not connected");
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
sendTelegramMessage("Hello from ESP32!");
|
||||
delay(60000); // Send every minute
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Message,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ServerUrl = "http://localhost:8880",
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$AppToken
|
||||
)
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$body = @{
|
||||
text = $Message
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
$headers = @{
|
||||
"Content-Type" = "application/json; charset=utf-8"
|
||||
"X-App-Token" = $AppToken
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri "$ServerUrl/send" -Method Post -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -Headers $headers
|
||||
Write-Host "Message sent successfully. Message ID: $($response.message_id)"
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error "Failed to send message: $_"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/tnproxy.db")
|
||||
|
||||
SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0")
|
||||
SERVER_PORT = int(os.getenv("SERVER_PORT", "8080"))
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
|
||||
LOG_DIR = os.getenv("LOG_DIR", f"{BASE_DIR}/logs")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
LOG_ROTATE_MAX_BYTES = int(os.getenv("LOG_ROTATE_MAX_BYTES", 10485760))
|
||||
LOG_ROTATE_BACKUP_COUNT = int(os.getenv("LOG_ROTATE_BACKUP_COUNT", 5))
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "")
|
||||
SMTP_TO = os.getenv("SMTP_TO", "")
|
||||
|
||||
ADMIN_SESSION_SECRET = os.getenv("ADMIN_SESSION_SECRET", SECRET_KEY)
|
||||
|
||||
ALLOWED_ADMIN_IPS = os.getenv("ALLOWED_ADMIN_IPS", "").strip()
|
||||
if ALLOWED_ADMIN_IPS:
|
||||
ALLOWED_ADMIN_IPS = [
|
||||
ip.strip() for ip in ALLOWED_ADMIN_IPS.split(",") if ip.strip()
|
||||
]
|
||||
else:
|
||||
ALLOWED_ADMIN_IPS = []
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config import DATABASE_URL
|
||||
from models import Base
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
tnproxy:
|
||||
build: .
|
||||
ports:
|
||||
- "8880:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///data/tnproxy.db
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me}
|
||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:-change-me}
|
||||
- ALLOWED_ADMIN_IPS=${ALLOWED_ADMIN_IPS:-}
|
||||
- LOG_DIR=/app/logs
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- LOG_ROTATE_MAX_BYTES=${LOG_ROTATE_MAX_BYTES:-10485760}
|
||||
- LOG_ROTATE_BACKUP_COUNT=${LOG_ROTATE_BACKUP_COUNT:-5}
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TO=${SMTP_TO:-}
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,76 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Application(Base):
|
||||
__tablename__ = "applications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
telegram_bot_token: Mapped[str] = mapped_column(String(255))
|
||||
telegram_chat_id: Mapped[str] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"token": self.token,
|
||||
"telegram_bot_token": self.telegram_bot_token,
|
||||
"telegram_chat_id": self.telegram_chat_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"is_active": self.is_active,
|
||||
}
|
||||
|
||||
|
||||
class LogSettings(Base):
|
||||
__tablename__ = "log_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
max_bytes: Mapped[int] = mapped_column(Integer, default=10485760)
|
||||
backup_count: Mapped[int] = mapped_column(Integer, default=5)
|
||||
log_level: Mapped[str] = mapped_column(String(20), default="INFO")
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode(), self.password_hash.encode())
|
||||
|
||||
@staticmethod
|
||||
def create_default(db) -> "AdminUser":
|
||||
default_password = "admin123"
|
||||
user = AdminUser(
|
||||
username="admin",
|
||||
password_hash=AdminUser.hash_password(default_password),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
@@ -0,0 +1,10 @@
|
||||
aiohttp>=3.9.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
pydantic>=2.5.0
|
||||
sqlalchemy>=2.0.0
|
||||
jinja2>=3.1.0
|
||||
httpx>=0.25.0
|
||||
bcrypt>=4.0.0
|
||||
aiosmtplib>=3.0.0
|
||||
itsdangerous>=2.0.0
|
||||
@@ -0,0 +1,578 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import aiosmtplib
|
||||
import uvicorn
|
||||
from email.message import EmailMessage
|
||||
from fastapi import FastAPI, HTTPException, Header, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.security import HTTPBasicCredentials, HTTPBasic
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from config import (
|
||||
ADMIN_SESSION_SECRET,
|
||||
ALLOWED_ADMIN_IPS,
|
||||
DATABASE_URL,
|
||||
LOG_DIR,
|
||||
LOG_LEVEL,
|
||||
LOG_ROTATE_BACKUP_COUNT,
|
||||
LOG_ROTATE_MAX_BYTES,
|
||||
SECRET_KEY,
|
||||
SERVER_HOST,
|
||||
SERVER_PORT,
|
||||
SMTP_FROM,
|
||||
SMTP_HOST,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_PORT,
|
||||
SMTP_TO,
|
||||
SMTP_USER,
|
||||
)
|
||||
from database import SessionLocal, init_db
|
||||
from models import AdminUser, Application, LogSettings
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.handlers.RotatingFileHandler(
|
||||
f"{LOG_DIR}/tnproxy.log",
|
||||
maxBytes=LOG_ROTATE_MAX_BYTES,
|
||||
backupCount=LOG_ROTATE_BACKUP_COUNT,
|
||||
),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class ApplicationCreate(BaseModel):
|
||||
name: str
|
||||
telegram_bot_token: str
|
||||
telegram_chat_id: str
|
||||
|
||||
|
||||
class LogSettingsUpdate(BaseModel):
|
||||
max_bytes: int
|
||||
backup_count: int
|
||||
log_level: str
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TelegramResponse(BaseModel):
|
||||
ok: bool
|
||||
message_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
async def send_email(subject: str, body: str):
|
||||
if not SMTP_HOST or not SMTP_TO:
|
||||
logger.warning("Email not configured, skipping notification")
|
||||
return
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = SMTP_FROM or SMTP_USER
|
||||
message["To"] = SMTP_TO
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=SMTP_HOST,
|
||||
port=SMTP_PORT,
|
||||
username=SMTP_USER,
|
||||
password=SMTP_PASSWORD,
|
||||
use_tls=True,
|
||||
)
|
||||
logger.info(f"Email sent: {subject}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
|
||||
|
||||
async def notify_login_success(username: str, ip: str):
|
||||
await send_email(
|
||||
"TNProxy - Successful Login",
|
||||
f"Successful login to TNProxy admin panel\n\nUsername: {username}\nIP: {ip}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
)
|
||||
|
||||
|
||||
async def notify_login_failure(username: str, ip: str):
|
||||
await send_email(
|
||||
"TNProxy - Failed Login Attempt",
|
||||
f"Failed login attempt to TNProxy admin panel\n\nUsername: {username}\nIP: {ip}\nTime: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
)
|
||||
|
||||
|
||||
async def send_to_telegram(text: str, bot_token: str, chat_id: str) -> TelegramResponse:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {"chat_id": chat_id, "text": text}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.post(url, json=payload) as response:
|
||||
data = await response.json()
|
||||
if response.status == 200 and data.get("ok"):
|
||||
return TelegramResponse(
|
||||
ok=True, message_id=data.get("result", {}).get("message_id")
|
||||
)
|
||||
else:
|
||||
return TelegramResponse(
|
||||
ok=False, error=data.get("description", "Unknown error")
|
||||
)
|
||||
except aiohttp.ClientError as e:
|
||||
return TelegramResponse(ok=False, error=str(e))
|
||||
|
||||
|
||||
def get_log_settings(db: Session) -> LogSettings:
|
||||
settings = db.query(LogSettings).first()
|
||||
if not settings:
|
||||
settings = LogSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def update_logging_config(settings: LogSettings):
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, settings.log_level))
|
||||
|
||||
for handler in root_logger.handlers[:]:
|
||||
if isinstance(handler, logging.handlers.RotatingFileHandler):
|
||||
handler.maxBytes = settings.max_bytes
|
||||
handler.backupCount = settings.backup_count
|
||||
|
||||
|
||||
def get_admin_user(db: Session) -> Optional[AdminUser]:
|
||||
return db.query(AdminUser).first()
|
||||
|
||||
|
||||
def check_ip_allowed(request: Request):
|
||||
if not ALLOWED_ADMIN_IPS:
|
||||
return True
|
||||
client_ip = request.client.host if request.client else None
|
||||
if client_ip in ALLOWED_ADMIN_IPS:
|
||||
return True
|
||||
logger.warning(f"Blocked admin access from IP: {client_ip}")
|
||||
return False
|
||||
|
||||
|
||||
def require_auth(request: Request):
|
||||
if not check_ip_allowed(request):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
if not request.session.get("admin_user"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not db.query(AdminUser).first():
|
||||
admin = AdminUser.create_default(db)
|
||||
logger.info(f"Created default admin user: {admin.username}")
|
||||
finally:
|
||||
db.close()
|
||||
logger.info("TNProxy started")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="TNProxy", lifespan=lifespan)
|
||||
app.add_middleware(SessionMiddleware, secret_key=ADMIN_SESSION_SECRET)
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
jinja_env = Environment(loader=FileSystemLoader(str(BASE_DIR / "templates")))
|
||||
|
||||
|
||||
def render_template(template_name: str, context: dict) -> str:
|
||||
template = jinja_env.get_template(template_name)
|
||||
return template.render(**context)
|
||||
|
||||
|
||||
def get_application_by_token(token: str, db: Session) -> Optional[Application]:
|
||||
return db.query(Application).filter(Application.token == token).first()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/send")
|
||||
async def send_message(
|
||||
request: MessageRequest,
|
||||
x_app_token: Optional[str] = Header(None, alias="X-App-Token"),
|
||||
):
|
||||
if not x_app_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="X-App-Token header is required",
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
app = get_application_by_token(x_app_token, db)
|
||||
if not app:
|
||||
logger.warning(f"Invalid token attempt: {x_app_token[:8]}...")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid application token",
|
||||
)
|
||||
if not app.is_active:
|
||||
logger.warning(f"Disabled app attempted to send: {app.name}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Application is disabled",
|
||||
)
|
||||
|
||||
if not request.text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Text is required",
|
||||
)
|
||||
|
||||
logger.info(f"Message from app '{app.name}': {request.text[:50]!r}")
|
||||
result = await send_to_telegram(
|
||||
request.text, app.telegram_bot_token, app.telegram_chat_id
|
||||
)
|
||||
|
||||
if result.ok:
|
||||
logger.info(f"Message sent successfully to {app.name}")
|
||||
return {"success": True, "message_id": result.message_id}
|
||||
else:
|
||||
logger.error(f"Telegram API error for {app.name}: {result.error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Telegram API error: {result.error}",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/logtn", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
html = render_template("logtn.html", {"request": request, "error": error})
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/logtn")
|
||||
async def login(request: Request, login_data: LoginRequest):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin = (
|
||||
db.query(AdminUser)
|
||||
.filter(AdminUser.username == login_data.username)
|
||||
.first()
|
||||
)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if admin and admin.check_password(login_data.password):
|
||||
admin.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
request.session["admin_user"] = admin.username
|
||||
await notify_login_success(login_data.username, client_ip)
|
||||
logger.info(f"Admin login: {login_data.username} from {client_ip}")
|
||||
return {"success": True, "redirect": "/adtn"}
|
||||
else:
|
||||
await notify_login_failure(login_data.username, client_ip)
|
||||
logger.warning(
|
||||
f"Failed login attempt: {login_data.username} from {client_ip}"
|
||||
)
|
||||
return {"success": False, "error": "Invalid credentials"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return {"success": True, "redirect": "/logtn"}
|
||||
|
||||
|
||||
@app.get("/adtn", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return Response(headers={"Location": "/logtn"}, status_code=302)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
apps = [app.to_dict() for app in db.query(Application).all()]
|
||||
log_settings = get_log_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
html = render_template(
|
||||
"index.html",
|
||||
{"request": request, "apps": apps, "settings": log_settings},
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/adtn/password", response_class=HTMLResponse)
|
||||
async def password_page(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return Response(headers={"Location": "/logtn"}, status_code=302)
|
||||
|
||||
html = render_template("password.html", {"request": request})
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/adtn/password")
|
||||
async def change_password(request: Request, password_data: PasswordChange):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return {"success": False, "error": "Not authenticated"}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin = db.query(AdminUser).first()
|
||||
if not admin.check_password(password_data.old_password):
|
||||
return {"success": False, "error": "Current password is incorrect"}
|
||||
|
||||
admin.password_hash = AdminUser.hash_password(password_data.new_password)
|
||||
db.commit()
|
||||
logger.info(f"Password changed for user: {admin.username}")
|
||||
return {"success": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/adtn/api", response_class=HTMLResponse)
|
||||
async def admin_api_docs(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return Response(headers={"Location": "/logtn"}, status_code=302)
|
||||
|
||||
html = render_template("api.html", {"request": request})
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/adtn/applications")
|
||||
async def create_application(request: Request, app_data: ApplicationCreate):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
app = Application(
|
||||
name=app_data.name,
|
||||
token=Application.generate_token(),
|
||||
telegram_bot_token=app_data.telegram_bot_token,
|
||||
telegram_chat_id=app_data.telegram_chat_id,
|
||||
)
|
||||
db.add(app)
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
logger.info(f"Created application: {app.name}")
|
||||
return {"id": app.id, "token": app.token}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.delete("/adtn/applications/{app_id}")
|
||||
async def delete_application(request: Request, app_id: int):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
app = db.query(Application).filter(Application.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
db.delete(app)
|
||||
db.commit()
|
||||
logger.info(f"Deleted application: {app.name}")
|
||||
return {"success": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/adtn/applications/{app_id}/toggle")
|
||||
async def toggle_application(request: Request, app_id: int):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
app = db.query(Application).filter(Application.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
app.is_active = not app.is_active
|
||||
db.commit()
|
||||
logger.info(f"Toggled application {app.name}: is_active={app.is_active}")
|
||||
return {"is_active": app.is_active}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/adtn/logs", response_class=HTMLResponse)
|
||||
async def admin_logs(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return Response(headers={"Location": "/logtn"}, status_code=302)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
log_settings = get_log_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
html = render_template(
|
||||
"logs.html",
|
||||
{"request": request, "settings": log_settings},
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/adtn/logs")
|
||||
async def update_log_settings(request: Request, settings: LogSettingsUpdate):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
log_settings = get_log_settings(db)
|
||||
log_settings.max_bytes = settings.max_bytes
|
||||
log_settings.backup_count = settings.backup_count
|
||||
log_settings.log_level = settings.log_level
|
||||
db.commit()
|
||||
|
||||
update_logging_config(log_settings)
|
||||
logger.info(
|
||||
f"Updated log settings: level={log_settings.log_level}, max_bytes={log_settings.max_bytes}, backup_count={log_settings.backup_count}"
|
||||
)
|
||||
return {"success": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/adtn/export", response_class=HTMLResponse)
|
||||
async def export_settings(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
return Response(headers={"Location": "/logtn"}, status_code=302)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
apps = db.query(Application).all()
|
||||
log_settings = get_log_settings(db)
|
||||
export_data = {
|
||||
"applications": [app.to_dict() for app in apps],
|
||||
"log_settings": {
|
||||
"log_level": log_settings.log_level,
|
||||
"max_bytes": log_settings.max_bytes,
|
||||
"backup_count": log_settings.backup_count,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
import json
|
||||
from fastapi.responses import Response
|
||||
|
||||
return Response(
|
||||
content=json.dumps(export_data, indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": "attachment; filename=tnproxy_backup.json"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/adtn/import")
|
||||
async def import_settings(request: Request):
|
||||
try:
|
||||
require_auth(request)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return {"success": False, "error": "Invalid JSON"}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if "applications" in data:
|
||||
for app_data in data["applications"]:
|
||||
existing = (
|
||||
db.query(Application)
|
||||
.filter(Application.token == app_data.get("token"))
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
app = Application(
|
||||
name=app_data["name"],
|
||||
token=app_data["token"],
|
||||
telegram_bot_token=app_data["telegram_bot_token"],
|
||||
telegram_chat_id=app_data["telegram_chat_id"],
|
||||
is_active=app_data.get("is_active", True),
|
||||
)
|
||||
db.add(app)
|
||||
|
||||
if "log_settings" in data:
|
||||
log_settings = get_log_settings(db)
|
||||
log_settings.log_level = data["log_settings"].get("log_level", "INFO")
|
||||
log_settings.max_bytes = data["log_settings"].get("max_bytes", 10485760)
|
||||
log_settings.backup_count = data["log_settings"].get("backup_count", 5)
|
||||
|
||||
db.commit()
|
||||
logger.info("Settings imported successfully")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Import error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT)
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TNProxy - API Docs</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
h2 { color: #555; margin-top: 30px; margin-bottom: 15px; }
|
||||
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
.method { display: inline-block; padding: 4px 8px; border-radius: 4px; font-weight: bold; font-size: 12px; }
|
||||
.method.get { background: #61affe; color: white; }
|
||||
.method.post { background: #49cc90; color: white; }
|
||||
.method.delete { background: #f93e3e; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/admin">Приложения</a>
|
||||
<a href="/admin/api">API Docs</a>
|
||||
</div>
|
||||
|
||||
<h1>API Документация</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Отправка сообщения</h2>
|
||||
<p><span class="method post">POST</span> <code>/send</code></p>
|
||||
<p><strong>Headers:</strong></p>
|
||||
<ul>
|
||||
<li><code>Content-Type: application/json</code></li>
|
||||
<li><code>X-App-Token: <ваш_токен_приложения></code></li>
|
||||
</ul>
|
||||
<p><strong>Body:</strong></p>
|
||||
<pre>{"text": "Hello, World!"}</pre>
|
||||
|
||||
<h3>Пример PowerShell:</h3>
|
||||
<pre>$body = @{text = "Alert!"} | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "http://localhost:8080/send" -Method Post -Body $body -Headers @{"Content-Type"="application/json"; "X-App-Token"="YOUR_APP_TOKEN"}</pre>
|
||||
|
||||
<h3>Пример cURL:</h3>
|
||||
<pre>curl -X POST http://localhost:8080/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-App-Token: YOUR_APP_TOKEN" \
|
||||
-d '{"text": "Hello!"}'</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Проверка здоровья</h2>
|
||||
<p><span class="method get">GET</span> <code>/health</code></p>
|
||||
<pre>{"status": "ok"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TNProxy - Telegram Notification Proxy</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { background: #006699; }
|
||||
.btn-secondary { background: #666; }
|
||||
.btn-danger { background: #dc3545; }
|
||||
.btn-danger:hover { background: #c82333; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
table { width: 100%; background: white; border-collapse: collapse; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.status.active { background: #d4edda; color: #155724; }
|
||||
.status.inactive { background: #f8d7da; color: #721c24; }
|
||||
.token { font-family: monospace; background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
||||
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
|
||||
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; }
|
||||
.modal.show { display: flex; }
|
||||
.modal-content { background: white; padding: 20px; border-radius: 8px; width: 400px; }
|
||||
.modal-actions { margin-top: 15px; text-align: right; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>TNProxy - Dashboard</h1>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/adtn">Приложения</a>
|
||||
<a href="/adtn/logs">Логи</a>
|
||||
<a href="/adtn/api">API Docs</a>
|
||||
<a href="/adtn/password">Пароль</a>
|
||||
<a href="/adtn/export">Экспорт</a>
|
||||
<a href="#" id="importBtn">Импорт</a>
|
||||
<a href="#" id="logout">Выход</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Приложения</h2>
|
||||
<button class="btn" onclick="showModal()">+ Добавить приложение</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Token</th>
|
||||
<th>Chat ID</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for app in apps %}
|
||||
<tr>
|
||||
<td>{{ app.id }}</td>
|
||||
<td>{{ app.name }}</td>
|
||||
<td><span class="token">{{ app.token }}</span></td>
|
||||
<td>{{ app.telegram_chat_id }}</td>
|
||||
<td>
|
||||
<span class="status {{ 'active' if app.is_active else 'inactive' }}">
|
||||
{{ 'Активно' if app.is_active else 'Отключено' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" onclick="toggleApp({{ app.id }})">
|
||||
{{ 'Отключить' if app.is_active else 'Включить' }}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteApp({{ app.id }})">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: #666;">Нет приложений</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="addModal">
|
||||
<div class="modal-content">
|
||||
<h3>Добавить приложение</h3>
|
||||
<form id="addForm">
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" name="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telegram Bot Token</label>
|
||||
<input type="text" name="telegram_bot_token" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telegram Chat ID</label>
|
||||
<input type="text" name="telegram_chat_id" required>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="hideModal()">Отмена</button>
|
||||
<button type="submit" class="btn">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showModal() { document.getElementById('addModal').classList.add('show'); }
|
||||
function hideModal() { document.getElementById('addModal').classList.remove('show'); }
|
||||
|
||||
document.getElementById('addForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const res = await fetch('/adtn/applications', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.token) {
|
||||
alert('Приложение создано!\n\nToken: ' + result.token + '\n\nСкопируйте токен - он больше не будет показан!');
|
||||
hideModal();
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
async function deleteApp(id) {
|
||||
if (!confirm('Удалить приложение?')) return;
|
||||
await fetch('/adtn/applications/' + id, { method: 'DELETE' });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function toggleApp(id) {
|
||||
await fetch('/adtn/applications/' + id + '/toggle', { method: 'POST' });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
document.getElementById('logout').onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
window.location.href = '/logtn';
|
||||
};
|
||||
|
||||
document.getElementById('importBtn').onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (ev) => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const res = await fetch('/adtn/import', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: text
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
alert('Настройки импортированы!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + result.error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TNProxy - Настройки логов</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { background: #006699; }
|
||||
.btn-secondary { background: #666; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
||||
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
|
||||
.help { font-size: 12px; color: #666; margin-top: 4px; }
|
||||
.log-path { font-family: monospace; background: #f8f9fa; padding: 4px 8px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="/adtn">Приложения</a>
|
||||
<a href="/adtn/logs">Логи</a>
|
||||
<a href="/adtn/api">API Docs</a>
|
||||
</div>
|
||||
|
||||
<h1>Настройки логирования</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Log Rotate</h2>
|
||||
<form id="logForm">
|
||||
<div class="form-group">
|
||||
<label>Уровень логирования</label>
|
||||
<select name="log_level">
|
||||
<option value="DEBUG" {% if settings.log_level == 'DEBUG' %}selected{% endif %}>DEBUG</option>
|
||||
<option value="INFO" {% if settings.log_level == 'INFO' %}selected{% endif %}>INFO</option>
|
||||
<option value="WARNING" {% if settings.log_level == 'WARNING' %}selected{% endif %}>WARNING</option>
|
||||
<option value="ERROR" {% if settings.log_level == 'ERROR' %}selected{% endif %}>ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Максимальный размер файла (байт)</label>
|
||||
<input type="number" name="max_bytes" value="{{ settings.max_bytes }}" min="1024">
|
||||
<div class="help">Текущее значение: {{ settings.max_bytes }} байт ({{ (settings.max_bytes / 1024 / 1024)|round(2) }} MB)</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество backup файлов</label>
|
||||
<input type="number" name="backup_count" value="{{ settings.backup_count }}" min="1" max="100">
|
||||
<div class="help">Сколько архивных файлов хранить</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Файл логов</h2>
|
||||
<p>Путь: <span class="log-path">/app/logs/tnproxy.log</span></p>
|
||||
<p class="help">В Docker подключите volume <code>./logs:/app/logs</code> для доступа к файлам</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('logForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = {
|
||||
log_level: formData.get('log_level'),
|
||||
max_bytes: parseInt(formData.get('max_bytes')),
|
||||
backup_count: parseInt(formData.get('backup_count'))
|
||||
};
|
||||
|
||||
const res = await fetch('/adtn/logs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Настройки сохранены!');
|
||||
} else {
|
||||
alert('Ошибка сохранения');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TNProxy - Вход</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.login-box { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
|
||||
h1 { color: #333; margin-bottom: 20px; text-align: center; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
||||
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
|
||||
input:focus { outline: none; border-color: #0088cc; }
|
||||
.btn { width: 100%; padding: 12px; background: #0088cc; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { background: #006699; }
|
||||
.error { color: #dc3545; margin-bottom: 15px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>TNProxy - Вход</h1>
|
||||
<div id="error" class="error" {% if not error %}style="display:none"{% endif %}>{{ error }}</div>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label>Логин</label>
|
||||
<input type="text" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const data = {
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password')
|
||||
};
|
||||
|
||||
const res = await fetch('/logtn', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
document.getElementById('error').textContent = result.error;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TNProxy - Смена пароля</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 500px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background: #0088cc; color: white; text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { background: #006699; }
|
||||
.btn-secondary { background: #666; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { margin-right: 15px; color: #0088cc; text-decoration: none; }
|
||||
.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
||||
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
|
||||
.success { color: #28a745; margin-bottom: 15px; }
|
||||
.error { color: #dc3545; margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>TNProxy</h1>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/adtn">Приложения</a>
|
||||
<a href="/adtn/logs">Логи</a>
|
||||
<a href="/adtn/api">API Docs</a>
|
||||
<a href="/adtn/password">Пароль</a>
|
||||
<a href="#" id="logout">Выход</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Смена пароля</h2>
|
||||
<div id="message"></div>
|
||||
<form id="passwordForm">
|
||||
<div class="form-group">
|
||||
<label>Текущий пароль</label>
|
||||
<input type="password" name="old_password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Новый пароль</label>
|
||||
<input type="password" name="new_password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Подтверждение пароля</label>
|
||||
<input type="password" name="confirm_password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Изменить пароль</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('logout').onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
document.getElementById('passwordForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
if (formData.get('new_password') !== formData.get('confirm_password')) {
|
||||
document.getElementById('message').innerHTML = '<div class="error">Пароли не совпадают</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
old_password: formData.get('old_password'),
|
||||
new_password: formData.get('new_password')
|
||||
};
|
||||
|
||||
const res = await fetch('/adtn/password', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
document.getElementById('message').innerHTML = '<div class="success">Пароль изменен!</div>';
|
||||
e.target.reset();
|
||||
} else {
|
||||
document.getElementById('message').innerHTML = '<div class="error">' + result.error + '</div>';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user