MVC rewrite: config TUI, MariaDB support, Tenor GIFs, clean structure
This commit is contained in:
parent
872052d265
commit
57257f6799
116
README.md
116
README.md
|
|
@ -1,53 +1,97 @@
|
||||||
# FuckingChat 🔒
|
# FuckingChat 🔒
|
||||||
|
|
||||||
Encrypted chat with channel support. Messages are encrypted at rest using Fernet (PBKDF2 + SHA256).
|
Encrypted chat with multi-channel support and Tenor GIFs.
|
||||||
|
Messages encrypted at rest (Fernet + PBKDF2/SHA256).
|
||||||
|
|
||||||
## Channels
|
## Quick start
|
||||||
|
|
||||||
- 💬 **General** — general discussion
|
|
||||||
- 🎮 **Games** — gaming chat
|
|
||||||
- 🎌 **Anime** — anime & manga
|
|
||||||
- ⚖️ **Politics** — politics discussion
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
pip install bottle cryptography sqlalchemy
|
pip install bottle cryptography sqlalchemy
|
||||||
|
|
||||||
# Run (development)
|
# First run — configure everything interactively
|
||||||
python index.py
|
python app.py -c
|
||||||
|
|
||||||
# Run (production - recommended)
|
# Then start the server
|
||||||
CHAT_SECRET="your-strong-secret" \
|
python app.py
|
||||||
CHAT_SALT="your-random-salt" \
|
|
||||||
CHAT_ADMIN_TOKEN="your-admin-token" \
|
|
||||||
CHAT_RATE_LIMIT=5 \
|
|
||||||
CHAT_HOST=0.0.0.0 \
|
|
||||||
CHAT_PORT=8080 \
|
|
||||||
python index.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Project structure (MVC)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
```
|
||||||
|---|---|---|
|
├── app.py # Entry point
|
||||||
| `CHAT_SECRET` | `default_secret_change_me` | Encryption key |
|
├── index.html # Frontend (single-page)
|
||||||
| `CHAT_SALT` | `default_salt_change_me` | PBKDF2 salt |
|
├── config/
|
||||||
| `CHAT_ADMIN_TOKEN` | (none) | Token for DELETE API |
|
│ ├── __init__.py
|
||||||
| `CHAT_RATE_LIMIT` | `5` | Max messages per window |
|
│ ├── schema.py # Dataclass definitions
|
||||||
| `CHAT_RATE_WINDOW` | `10` | Rate limit window (seconds) |
|
│ └── config.py # JSON serializer / loader
|
||||||
| `CHAT_MAX_MSG` | `2000` | Max message length |
|
├── models/
|
||||||
| `CHAT_DB_PATH` | `./chat.db` | SQLite database path |
|
│ ├── __init__.py
|
||||||
| `CHAT_HOST` | `localhost` | Listen address |
|
│ ├── base.py # SQLAlchemy ORM + engine factory
|
||||||
| `CHAT_PORT` | `8080` | Listen port |
|
│ └── db.py # DBfrontend (encrypted CRUD)
|
||||||
| `CHAT_DEBUG` | `0` | Debug mode (set to `1` for dev) |
|
├── controllers/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── chat.py # Chat API endpoints
|
||||||
|
│ └── tenor.py # Tenor GIF proxy
|
||||||
|
├── views/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── config_tui.py # curses TUI configuration wizard
|
||||||
|
├── Anime/README.md
|
||||||
|
├── Games/README.md
|
||||||
|
├── Politics/README.md
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
## Security Features
|
## Configuration
|
||||||
|
|
||||||
|
Run `python app.py -c` to open the curses TUI:
|
||||||
|
|
||||||
|
- **Server** — host, port, debug mode
|
||||||
|
- **Security** — encryption secret, salt, admin token, rate limits
|
||||||
|
- **Database** — SQLite (default) or MariaDB
|
||||||
|
- **Tenor API** — API key for GIF search
|
||||||
|
|
||||||
|
Config is saved as `chat_config.json` in the project root.
|
||||||
|
Override path: `python app.py --config /path/to/config.json`
|
||||||
|
|
||||||
|
### Environment overrides
|
||||||
|
|
||||||
|
| Variable | Overrides config field |
|
||||||
|
|---|---|
|
||||||
|
| `CHAT_CONFIG` | Config file path |
|
||||||
|
| `CHAT_SECRET` | Encryption secret |
|
||||||
|
| `CHAT_SALT` | Encryption salt |
|
||||||
|
| `CHAT_ADMIN_TOKEN` | Admin token |
|
||||||
|
| `CHAT_RATE_LIMIT` | Rate limit |
|
||||||
|
| `CHAT_RATE_WINDOW` | Rate window (sec) |
|
||||||
|
| `CHAT_MAX_MSG` | Max message length |
|
||||||
|
| `CHAT_DB_PATH` | SQLite path |
|
||||||
|
| `CHAT_HOST` | Listen address |
|
||||||
|
| `CHAT_PORT` | Listen port |
|
||||||
|
| `CHAT_DEBUG` | Debug mode (1/0) |
|
||||||
|
| `TENOR_API_KEY` | Tenor API key |
|
||||||
|
|
||||||
|
## MariaDB setup
|
||||||
|
|
||||||
|
1. Choose **mariadb** as engine in config TUI.
|
||||||
|
2. Fill in host, port, user, password, database name.
|
||||||
|
3. Install driver: `pip install pymysql`
|
||||||
|
4. Start the server.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
- ✅ Messages encrypted at rest (AES via Fernet)
|
- ✅ Messages encrypted at rest (AES via Fernet)
|
||||||
- ✅ Rate limiting (configurable)
|
- ✅ Rate limiting (configurable)
|
||||||
- ✅ IP hashing (privacy)
|
- ✅ IP hashing (privacy)
|
||||||
- ✅ XSS protection (HTML tag stripping)
|
- ✅ XSS protection (HTML tag stripping, inline-gif whitelist)
|
||||||
- ✅ Admin token required for deletion
|
- ✅ Admin token required for DELETE
|
||||||
- ✅ No debug mode in production
|
- ✅ No debug mode in production
|
||||||
|
- ✅ API keys stored server-side only (Tenor proxy)
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/channels` — list channels
|
||||||
|
- `GET /api/messages?channel=X` — get messages
|
||||||
|
- `POST /api/messages` — send message `{"channel":"X","message":"..."}`
|
||||||
|
- `DELETE /api/messages/:id` — delete (requires `Authorization: Bearer <admin_token>`)
|
||||||
|
- `GET /api/gifs/trending` — trending GIFs
|
||||||
|
- `GET /api/gifs/search?q=...` — search GIFs
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""
|
||||||
|
app.py — FuckingChat entry point.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python app.py # run server
|
||||||
|
python app.py --configure # run TUI configurator
|
||||||
|
python app.py --config path.json # specify config file
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from bottle import Bottle, static_file, run
|
||||||
|
|
||||||
|
from config.config import load, save
|
||||||
|
from config.schema import AppConfig
|
||||||
|
from models.db import DBfrontend
|
||||||
|
from controllers.chat import init as chat_init, register_routes as chat_routes
|
||||||
|
from controllers.tenor import init as tenor_init, register_routes as tenor_routes
|
||||||
|
|
||||||
|
# Override config path from CLI
|
||||||
|
if "--config" in sys.argv:
|
||||||
|
idx = sys.argv.index("--config")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
os.environ["CHAT_CONFIG"] = sys.argv[idx + 1]
|
||||||
|
|
||||||
|
cfg: AppConfig = load()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db() -> DBfrontend:
|
||||||
|
return DBfrontend(
|
||||||
|
cfg.db,
|
||||||
|
secret=cfg.security.secret.encode(),
|
||||||
|
salt=cfg.security.salt.encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Bottle:
|
||||||
|
app = Bottle()
|
||||||
|
|
||||||
|
# Static frontend
|
||||||
|
static_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def serve_index():
|
||||||
|
return static_file("index.html", root=static_dir)
|
||||||
|
|
||||||
|
@app.route("/static/<filename:path>")
|
||||||
|
def serve_static(filename):
|
||||||
|
return static_file(filename, root=os.path.join(static_dir, "static"))
|
||||||
|
|
||||||
|
# Init controllers
|
||||||
|
db = _make_db()
|
||||||
|
chat_init(db, cfg.security)
|
||||||
|
tenor_init(cfg.tenor)
|
||||||
|
|
||||||
|
# Register route blueprints
|
||||||
|
chat_routes(app)
|
||||||
|
tenor_routes(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
app = create_app()
|
||||||
|
host = cfg.server.host
|
||||||
|
port = cfg.server.port
|
||||||
|
debug = cfg.server.debug
|
||||||
|
if debug:
|
||||||
|
print("⚠️ DEBUG MODE — do not use in production!")
|
||||||
|
print(f"🚀 Chat running on http://{host}:{port}")
|
||||||
|
run(app=app, host=host, port=port, debug=debug, reloader=debug)
|
||||||
|
|
||||||
|
|
||||||
|
def run_configurator():
|
||||||
|
from views.config_tui import main
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--configure" in sys.argv or "-c" in sys.argv:
|
||||||
|
run_configurator()
|
||||||
|
else:
|
||||||
|
run_server()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# config
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""
|
||||||
|
config/config.py — config loader: file → AppConfig.
|
||||||
|
Stored as JSON in the same directory as the app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from .schema import AppConfig, DatabaseConfig, SecurityConfig, ServerConfig, TenorConfig
|
||||||
|
|
||||||
|
CONFIG_FILE = os.environ.get("CHAT_CONFIG", "chat_config.json")
|
||||||
|
|
||||||
|
def _config_path() -> str:
|
||||||
|
"""Return absolute path to config file."""
|
||||||
|
p = Path(CONFIG_FILE)
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
return str(Path(__file__).resolve().parent.parent / CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> AppConfig:
|
||||||
|
path = _config_path()
|
||||||
|
cfg = AppConfig()
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if "db" in data:
|
||||||
|
cfg.db = DatabaseConfig(**{**cfg.db.__dict__, **data["db"]})
|
||||||
|
if "security" in data:
|
||||||
|
cfg.security = SecurityConfig(**{**cfg.security.__dict__, **data["security"]})
|
||||||
|
if "server" in data:
|
||||||
|
cfg.server = ServerConfig(**{**cfg.server.__dict__, **data["server"]})
|
||||||
|
if "tenor" in data:
|
||||||
|
cfg.tenor = TenorConfig(**{**cfg.tenor.__dict__, **data["tenor"]})
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save(cfg: AppConfig):
|
||||||
|
path = _config_path()
|
||||||
|
data = {
|
||||||
|
"db": cfg.db.__dict__,
|
||||||
|
"security": cfg.security.__dict__,
|
||||||
|
"server": cfg.server.__dict__,
|
||||||
|
"tenor": cfg.tenor.__dict__,
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""
|
||||||
|
config/schema.py — schema & defaults for chat configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseConfig:
|
||||||
|
engine: str = "sqlite" # "sqlite" | "mariadb"
|
||||||
|
sqlite_path: str = "chat.db"
|
||||||
|
mariadb_host: str = "localhost"
|
||||||
|
mariadb_port: int = 3306
|
||||||
|
mariadb_user: str = "chat"
|
||||||
|
mariadb_password: str = "chat"
|
||||||
|
mariadb_database: str = "chat"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SecurityConfig:
|
||||||
|
secret: str = "default_secret_change_me"
|
||||||
|
salt: str = "default_salt_change_me"
|
||||||
|
admin_token: str = ""
|
||||||
|
rate_limit: int = 5
|
||||||
|
rate_window: int = 10
|
||||||
|
max_message_length: int = 2000
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServerConfig:
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8080
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TenorConfig:
|
||||||
|
api_key: str = ""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
db: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||||
|
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||||
|
server: ServerConfig = field(default_factory=ServerConfig)
|
||||||
|
tenor: TenorConfig = field(default_factory=TenorConfig)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# controllers/__init__.py
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""
|
||||||
|
controllers/chat.py — chat API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from bottle import route, request, response
|
||||||
|
|
||||||
|
from models.db import DBfrontend
|
||||||
|
|
||||||
|
VALID_CHANNELS = {"general", "games", "anime", "politics"}
|
||||||
|
CHANNEL_NAMES = {
|
||||||
|
"general": "General",
|
||||||
|
"games": "Games",
|
||||||
|
"anime": "Anime",
|
||||||
|
"politics": "Politics",
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-memory rate store: ip → [timestamps]
|
||||||
|
rate_store: dict[str, list[float]] = {}
|
||||||
|
_rate_limit: int = 5
|
||||||
|
_rate_window: int = 10
|
||||||
|
_admin_token: str = ""
|
||||||
|
_salt: str = ""
|
||||||
|
_db: DBfrontend | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init(db: DBfrontend, sec_cfg):
|
||||||
|
global _db, _rate_limit, _rate_window, _admin_token, _salt
|
||||||
|
_db = db
|
||||||
|
_rate_limit = sec_cfg.rate_limit
|
||||||
|
_rate_window = sec_cfg.rate_window
|
||||||
|
_admin_token = sec_cfg.admin_token
|
||||||
|
_salt = sec_cfg.salt
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate(ip: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
cutoff = now - _rate_window
|
||||||
|
if ip in rate_store:
|
||||||
|
rate_store[ip] = [t for t in rate_store[ip] if t > cutoff]
|
||||||
|
if len(rate_store[ip]) >= _rate_limit:
|
||||||
|
return False
|
||||||
|
rate_store[ip].append(now)
|
||||||
|
else:
|
||||||
|
rate_store[ip] = [now]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_xss(content: str) -> str:
|
||||||
|
"""Strip HTML tags except allowed <img class="inline-gif"> for Tenor."""
|
||||||
|
allowed = r'<img class="inline-gif" src="[^"]+">'
|
||||||
|
saved = re.findall(allowed, content)
|
||||||
|
content = re.sub(r"<[^>]*>", "", content)
|
||||||
|
if saved:
|
||||||
|
content = (content + "\n" + "".join(saved)).strip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
from bottle import Bottle
|
||||||
|
|
||||||
|
@app.route("/api/channels", method="GET")
|
||||||
|
def list_channels():
|
||||||
|
response.content_type = "application/json"
|
||||||
|
return {"success": True, "channels": [{"id": k, "name": v} for k, v in CHANNEL_NAMES.items()]}
|
||||||
|
|
||||||
|
@app.route("/api/messages", method="POST")
|
||||||
|
def post_message():
|
||||||
|
ip = request.environ.get("REMOTE_ADDR", "127.0.0.1")
|
||||||
|
if not _check_rate(ip):
|
||||||
|
response.status = 429
|
||||||
|
return {"success": False, "error": "Rate limit exceeded. Slow down."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "No data"}
|
||||||
|
|
||||||
|
channel = data.get("channel", "general")
|
||||||
|
content = data.get("message", "").strip()
|
||||||
|
|
||||||
|
if channel not in VALID_CHANNELS:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Invalid channel"}
|
||||||
|
if not content:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Empty message"}
|
||||||
|
if len(content) > 2000:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Message too long"}
|
||||||
|
|
||||||
|
content = _strip_xss(content)
|
||||||
|
|
||||||
|
ip_hash = hashlib.sha256((_salt + ip).encode()).hexdigest()[:16]
|
||||||
|
msg_id = _db.add_message(channel, ip_hash, content)
|
||||||
|
|
||||||
|
return {"success": True, "message_id": msg_id, "ip": ip_hash}
|
||||||
|
except Exception as e:
|
||||||
|
response.status = 500
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@app.route("/api/messages", method="GET")
|
||||||
|
def get_messages():
|
||||||
|
try:
|
||||||
|
channel = request.query.get("channel", "general")
|
||||||
|
limit = min(int(request.query.get("limit", 50)), 200)
|
||||||
|
if channel not in VALID_CHANNELS:
|
||||||
|
channel = "general"
|
||||||
|
msgs = _db.get_messages(channel=channel, limit=limit)
|
||||||
|
return {"success": True, "messages": msgs}
|
||||||
|
except Exception as e:
|
||||||
|
response.status = 500
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@app.route("/api/messages/<message_id:int>", method="DELETE")
|
||||||
|
def delete_message(message_id):
|
||||||
|
auth = request.get_header("Authorization", "")
|
||||||
|
token = auth.replace("Bearer ", "")
|
||||||
|
if _admin_token and token != _admin_token:
|
||||||
|
response.status = 403
|
||||||
|
return {"success": False, "error": "Forbidden"}
|
||||||
|
try:
|
||||||
|
ok = _db.delete_message(message_id)
|
||||||
|
return {"success": ok}
|
||||||
|
except Exception as e:
|
||||||
|
response.status = 500
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
controllers/tenor.py — Tenor GIF API proxy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from bottle import route, request, response
|
||||||
|
|
||||||
|
|
||||||
|
_api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def init(tenor_cfg):
|
||||||
|
global _api_key
|
||||||
|
_api_key = tenor_cfg.api_key
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
@app.route("/api/gifs/trending", method="GET")
|
||||||
|
def gifs_trending():
|
||||||
|
if not _api_key:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Tenor API key not configured"}
|
||||||
|
try:
|
||||||
|
limit = request.query.get("limit", "20")
|
||||||
|
url = f"https://tenor.googleapis.com/v2/trending?key={_api_key}&limit={limit}&media_filter=tinygif"
|
||||||
|
with urllib.request.urlopen(url) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return {"success": True, "results": data.get("results", [])}
|
||||||
|
except Exception as e:
|
||||||
|
response.status = 502
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
@app.route("/api/gifs/search", method="GET")
|
||||||
|
def gifs_search():
|
||||||
|
if not _api_key:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Tenor API key not configured"}
|
||||||
|
try:
|
||||||
|
q = request.query.get("q", "")
|
||||||
|
if not q:
|
||||||
|
response.status = 400
|
||||||
|
return {"success": False, "error": "Missing query"}
|
||||||
|
limit = request.query.get("limit", "20")
|
||||||
|
url = f"https://tenor.googleapis.com/v2/search?key={_api_key}&q={urllib.parse.quote(q)}&limit={limit}&media_filter=tinygif"
|
||||||
|
with urllib.request.urlopen(url) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return {"success": True, "results": data.get("results", [])}
|
||||||
|
except Exception as e:
|
||||||
|
response.status = 502
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
267
index.py
267
index.py
|
|
@ -1,267 +0,0 @@
|
||||||
from bottle import route, request, response, run, static_file, template
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
import base64
|
|
||||||
import sqlalchemy
|
|
||||||
from sqlalchemy import create_engine, String, Integer, Text
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# --- Security config from environment ---
|
|
||||||
SALT = os.environ.get('CHAT_SALT', 'default_salt_change_me').encode()
|
|
||||||
PASSWORD = os.environ.get('CHAT_SECRET', 'default_secret_change_me').encode()
|
|
||||||
ADMIN_TOKEN = os.environ.get('CHAT_ADMIN_TOKEN', None)
|
|
||||||
RATE_LIMIT = int(os.environ.get('CHAT_RATE_LIMIT', 5)) # messages per window
|
|
||||||
RATE_WINDOW = int(os.environ.get('CHAT_RATE_WINDOW', 10)) # seconds
|
|
||||||
MAX_MSG_LENGTH = int(os.environ.get('CHAT_MAX_MSG', 2000))
|
|
||||||
DB_PATH = os.environ.get('CHAT_DB_PATH', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'chat.db'))
|
|
||||||
TENOR_API_KEY = os.environ.get('TENOR_API_KEY', '')
|
|
||||||
|
|
||||||
# --- Rate limiting (in-memory) ---
|
|
||||||
rate_store = {}
|
|
||||||
|
|
||||||
def check_rate_limit(ip):
|
|
||||||
now = time.time()
|
|
||||||
window_start = now - RATE_WINDOW
|
|
||||||
if ip in rate_store:
|
|
||||||
rate_store[ip] = [t for t in rate_store[ip] if t > window_start]
|
|
||||||
if len(rate_store[ip]) >= RATE_LIMIT:
|
|
||||||
return False
|
|
||||||
rate_store[ip].append(now)
|
|
||||||
else:
|
|
||||||
rate_store[ip] = [now]
|
|
||||||
return True
|
|
||||||
|
|
||||||
# --- Encryption ---
|
|
||||||
def get_cipher():
|
|
||||||
kdf = PBKDF2HMAC(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=32,
|
|
||||||
salt=SALT,
|
|
||||||
iterations=100000,
|
|
||||||
)
|
|
||||||
key = base64.urlsafe_b64encode(kdf.derive(PASSWORD))
|
|
||||||
return Fernet(key)
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class Messages(Base):
|
|
||||||
__tablename__ = "messages"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
channel: Mapped[str] = mapped_column(String(32), nullable=False, default='general')
|
|
||||||
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
timestamp: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
||||||
|
|
||||||
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
class DBfrontend():
|
|
||||||
def __init__(self):
|
|
||||||
self.Session = sessionmaker(bind=engine)
|
|
||||||
self.cipher = get_cipher()
|
|
||||||
|
|
||||||
def _encrypt(self, message):
|
|
||||||
return self.cipher.encrypt(message.encode()).decode()
|
|
||||||
|
|
||||||
def _decrypt(self, encrypted):
|
|
||||||
try:
|
|
||||||
return self.cipher.decrypt(encrypted.encode()).decode()
|
|
||||||
except:
|
|
||||||
return "[Decryption failed]"
|
|
||||||
|
|
||||||
def add_message(self, channel, ip, content):
|
|
||||||
with self.Session() as session:
|
|
||||||
msg = Messages(
|
|
||||||
channel=channel,
|
|
||||||
ip=ip,
|
|
||||||
content=self._encrypt(content),
|
|
||||||
timestamp=int(time.time() * 1000)
|
|
||||||
)
|
|
||||||
session.add(msg)
|
|
||||||
session.commit()
|
|
||||||
return msg.id
|
|
||||||
|
|
||||||
def get_messages(self, channel='general', limit=50):
|
|
||||||
with self.Session() as session:
|
|
||||||
q = session.query(Messages).filter(Messages.channel == channel)
|
|
||||||
q = q.order_by(Messages.id.desc()).limit(limit)
|
|
||||||
msgs = q.all()
|
|
||||||
result = []
|
|
||||||
for msg in reversed(msgs):
|
|
||||||
result.append({
|
|
||||||
'id': msg.id,
|
|
||||||
'channel': msg.channel,
|
|
||||||
'ip': msg.ip,
|
|
||||||
'content': self._decrypt(msg.content),
|
|
||||||
'timestamp': msg.timestamp
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
def delete_message(self, msg_id):
|
|
||||||
with self.Session() as session:
|
|
||||||
msg = session.query(Messages).get(msg_id)
|
|
||||||
if msg:
|
|
||||||
session.delete(msg)
|
|
||||||
session.commit()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
db = DBfrontend()
|
|
||||||
|
|
||||||
VALID_CHANNELS = {'general', 'games', 'anime', 'politics'}
|
|
||||||
CHANNEL_NAMES = {
|
|
||||||
'general': 'General',
|
|
||||||
'games': 'Games',
|
|
||||||
'anime': 'Anime',
|
|
||||||
'politics': 'Politics'
|
|
||||||
}
|
|
||||||
|
|
||||||
@route('/')
|
|
||||||
def serve_frontend():
|
|
||||||
return static_file('index.html', root=os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
@route('/api/channels', method='GET')
|
|
||||||
def list_channels():
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
channels = [{'id': k, 'name': v} for k, v in CHANNEL_NAMES.items()]
|
|
||||||
return {'success': True, 'channels': channels}
|
|
||||||
|
|
||||||
@route('/api/messages', method='POST')
|
|
||||||
def post_message():
|
|
||||||
ip = request.environ.get('REMOTE_ADDR', '127.0.0.1')
|
|
||||||
|
|
||||||
if not check_rate_limit(ip):
|
|
||||||
response.status = 429
|
|
||||||
return {'success': False, 'error': 'Rate limit exceeded. Slow down.'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = request.json
|
|
||||||
if not data:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'No data provided'}
|
|
||||||
|
|
||||||
channel = data.get('channel', 'general')
|
|
||||||
content = data.get('message', '').strip()
|
|
||||||
|
|
||||||
if channel not in VALID_CHANNELS:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'Invalid channel'}
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'Empty message'}
|
|
||||||
|
|
||||||
if len(content) > MAX_MSG_LENGTH:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': f'Message too long (max {MAX_MSG_LENGTH} chars)'}
|
|
||||||
|
|
||||||
# XSS filter - strip HTML tags except allowed inline-gif
|
|
||||||
allowed_gif = r'<img class="inline-gif" src="[^"]+">'
|
|
||||||
# Save allowed GIF tags before stripping
|
|
||||||
saved_gifs = re.findall(allowed_gif, content)
|
|
||||||
content = re.sub(r'<[^>]*>', '', content)
|
|
||||||
# Re-insert only saved GIFs at the end
|
|
||||||
if saved_gifs:
|
|
||||||
content = (content + '\n' + ''.join(saved_gifs)).strip()
|
|
||||||
|
|
||||||
ip_hash = hashlib.sha256((os.environ.get('CHAT_SALT', 'salt') + ip).encode()).hexdigest()[:16]
|
|
||||||
msg_id = db.add_message(channel, ip_hash, content)
|
|
||||||
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
return {'success': True, 'message_id': msg_id, 'ip': ip_hash}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
response.status = 500
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
@route('/api/messages', method='GET')
|
|
||||||
def get_messages():
|
|
||||||
try:
|
|
||||||
channel = request.query.get('channel', 'general')
|
|
||||||
limit = min(int(request.query.get('limit', 50)), 200)
|
|
||||||
|
|
||||||
if channel not in VALID_CHANNELS:
|
|
||||||
channel = 'general'
|
|
||||||
|
|
||||||
messages = db.get_messages(channel=channel, limit=limit)
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
return {'success': True, 'messages': messages}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
response.status = 500
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
@route('/api/gifs/trending', method='GET')
|
|
||||||
def gifs_trending():
|
|
||||||
if not TENOR_API_KEY:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'Tenor API key not configured'}
|
|
||||||
try:
|
|
||||||
limit = request.query.get('limit', '20')
|
|
||||||
url = f'https://tenor.googleapis.com/v2/trending?key={TENOR_API_KEY}&limit={limit}&media_filter=tinygif'
|
|
||||||
with urllib.request.urlopen(url) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
return {'success': True, 'results': data.get('results', [])}
|
|
||||||
except Exception as e:
|
|
||||||
response.status = 502
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
@route('/api/gifs/search', method='GET')
|
|
||||||
def gifs_search():
|
|
||||||
if not TENOR_API_KEY:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'Tenor API key not configured'}
|
|
||||||
try:
|
|
||||||
q = request.query.get('q', '')
|
|
||||||
if not q:
|
|
||||||
response.status = 400
|
|
||||||
return {'success': False, 'error': 'Missing query'}
|
|
||||||
limit = request.query.get('limit', '20')
|
|
||||||
url = f'https://tenor.googleapis.com/v2/search?key={TENOR_API_KEY}&q={urllib.parse.quote(q)}&limit={limit}&media_filter=tinygif'
|
|
||||||
with urllib.request.urlopen(url) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
return {'success': True, 'results': data.get('results', [])}
|
|
||||||
except Exception as e:
|
|
||||||
response.status = 502
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
@route('/api/messages/<message_id>', method='DELETE')
|
|
||||||
def delete_message(message_id):
|
|
||||||
# Admin token required for deletion
|
|
||||||
auth = request.get_header('Authorization', '')
|
|
||||||
token = auth.replace('Bearer ', '')
|
|
||||||
|
|
||||||
if ADMIN_TOKEN and token != ADMIN_TOKEN:
|
|
||||||
response.status = 403
|
|
||||||
return {'success': False, 'error': 'Forbidden'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
success = db.delete_message(int(message_id))
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
return {'success': success}
|
|
||||||
except Exception as e:
|
|
||||||
response.status = 500
|
|
||||||
return {'success': False, 'error': str(e)}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
host = os.environ.get('CHAT_HOST', 'localhost')
|
|
||||||
port = int(os.environ.get('CHAT_PORT', 8080))
|
|
||||||
debug_mode = os.environ.get('CHAT_DEBUG', '0') == '1'
|
|
||||||
|
|
||||||
if debug_mode:
|
|
||||||
print(f"⚠️ WARNING: Debug mode is ON - do not use in production!")
|
|
||||||
|
|
||||||
run(host=host, port=port, debug=debug_mode)
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# models/__init__.py
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""
|
||||||
|
models/base.py — declarative base & the Messages model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Messages(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
channel: Mapped[str] = mapped_column(String(32), nullable=False, default="general")
|
||||||
|
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
timestamp: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(db_cfg):
|
||||||
|
"""Create a SQLAlchemy engine from DatabaseConfig."""
|
||||||
|
if db_cfg.engine == "mariadb":
|
||||||
|
url = (
|
||||||
|
f"mysql+pymysql://{db_cfg.mariadb_user}:{db_cfg.mariadb_password}"
|
||||||
|
f"@{db_cfg.mariadb_host}:{db_cfg.mariadb_port}/{db_cfg.mariadb_database}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
url = f"sqlite:///{db_cfg.sqlite_path}"
|
||||||
|
engine = create_engine(url, echo=False)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
return engine
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""
|
||||||
|
models/db.py — DBfrontend: encrypted message store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
import base64
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from .base import make_engine, Messages
|
||||||
|
|
||||||
|
|
||||||
|
class DBfrontend:
|
||||||
|
def __init__(self, db_cfg, secret: bytes, salt: bytes):
|
||||||
|
self.engine = make_engine(db_cfg)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.cipher = self._get_cipher(secret, salt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_cipher(secret: bytes, salt: bytes) -> Fernet:
|
||||||
|
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100_000)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(secret))
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
def _encrypt(self, message: str) -> str:
|
||||||
|
return self.cipher.encrypt(message.encode()).decode()
|
||||||
|
|
||||||
|
def _decrypt(self, encrypted: str) -> str:
|
||||||
|
try:
|
||||||
|
return self.cipher.decrypt(encrypted.encode()).decode()
|
||||||
|
except Exception:
|
||||||
|
return "[Decryption failed]"
|
||||||
|
|
||||||
|
def add_message(self, channel: str, ip: str, content: str) -> int:
|
||||||
|
with self.Session() as session:
|
||||||
|
msg = Messages(
|
||||||
|
channel=channel,
|
||||||
|
ip=ip,
|
||||||
|
content=self._encrypt(content),
|
||||||
|
timestamp=int(time.time() * 1000),
|
||||||
|
)
|
||||||
|
session.add(msg)
|
||||||
|
session.commit()
|
||||||
|
return msg.id
|
||||||
|
|
||||||
|
def get_messages(self, channel: str = "general", limit: int = 50) -> list:
|
||||||
|
with self.Session() as session:
|
||||||
|
q = (
|
||||||
|
session.query(Messages)
|
||||||
|
.filter(Messages.channel == channel)
|
||||||
|
.order_by(Messages.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for msg in reversed(q.all()):
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": msg.id,
|
||||||
|
"channel": msg.channel,
|
||||||
|
"ip": msg.ip,
|
||||||
|
"content": self._decrypt(msg.content),
|
||||||
|
"timestamp": msg.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_message(self, msg_id: int) -> bool:
|
||||||
|
with self.Session() as session:
|
||||||
|
msg = session.get(Messages, msg_id)
|
||||||
|
if msg:
|
||||||
|
session.delete(msg)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# views/__init__.py
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""
|
||||||
|
views/config_tui.py — curses-based configuration wizard.
|
||||||
|
|
||||||
|
Run: python -m views.config_tui
|
||||||
|
Or: python views/config_tui.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure project root is on path when run directly
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from config.schema import AppConfig, DatabaseConfig, SecurityConfig, ServerConfig, TenorConfig
|
||||||
|
from config.config import load, save
|
||||||
|
|
||||||
|
# ── field descriptors ──────────────────────────────────────
|
||||||
|
SECTION_FIELDS = [
|
||||||
|
("server", "Server", [
|
||||||
|
("host", "Host", str, "0.0.0.0"),
|
||||||
|
("port", "Port", int, 8080),
|
||||||
|
("debug", "Debug mode", bool, False),
|
||||||
|
]),
|
||||||
|
("security", "Security", [
|
||||||
|
("secret", "Encryption secret", str, "default_secret_change_me"),
|
||||||
|
("salt", "Encryption salt", str, "default_salt_change_me"),
|
||||||
|
("admin_token", "Admin token", str, ""),
|
||||||
|
("rate_limit", "Rate limit (msg)", int, 5),
|
||||||
|
("rate_window", "Rate window (sec)", int, 10),
|
||||||
|
("max_message_length", "Max msg length", int, 2000),
|
||||||
|
]),
|
||||||
|
("db", "Database", [
|
||||||
|
("engine", "Engine (sqlite|mariadb)", str, "sqlite"),
|
||||||
|
("sqlite_path", "SQLite path", str, "chat.db"),
|
||||||
|
("mariadb_host", "MariaDB host", str, "localhost"),
|
||||||
|
("mariadb_port", "MariaDB port", int, 3306),
|
||||||
|
("mariadb_user", "MariaDB user", str, "chat"),
|
||||||
|
("mariadb_password", "MariaDB password", str, "chat"),
|
||||||
|
("mariadb_database", "MariaDB database", str, "chat"),
|
||||||
|
]),
|
||||||
|
("tenor", "Tenor API", [
|
||||||
|
("api_key", "API key", str, ""),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────
|
||||||
|
def draw_menu(std, items, selected, top, height, width, title):
|
||||||
|
"""Draw a scrollable list and return the new top index."""
|
||||||
|
std.attron(curses.A_REVERSE)
|
||||||
|
std.addstr(0, 0, f" {title} ".center(width, "─"))
|
||||||
|
std.attroff(curses.A_REVERSE)
|
||||||
|
|
||||||
|
n = len(items)
|
||||||
|
visible = height - 2 # header + status line
|
||||||
|
# clamp top
|
||||||
|
if selected < top:
|
||||||
|
top = selected
|
||||||
|
if selected >= top + visible:
|
||||||
|
top = selected - visible + 1
|
||||||
|
top = max(0, min(top, n - visible))
|
||||||
|
|
||||||
|
for i in range(visible):
|
||||||
|
idx = top + i
|
||||||
|
if idx >= n:
|
||||||
|
break
|
||||||
|
line = f" {items[idx]}"
|
||||||
|
if idx == selected:
|
||||||
|
std.attron(curses.A_REVERSE)
|
||||||
|
std.addstr(1 + i, 0, line.ljust(width))
|
||||||
|
std.attroff(curses.A_REVERSE)
|
||||||
|
else:
|
||||||
|
std.addstr(1 + i, 0, line.ljust(width))
|
||||||
|
|
||||||
|
# status bar
|
||||||
|
std.attron(curses.A_REVERSE)
|
||||||
|
std.addstr(height - 1, 0, f" {selected+1}/{n} ↑↓ scroll ← back ".ljust(width))
|
||||||
|
std.attroff(curses.A_REVERSE)
|
||||||
|
|
||||||
|
return top
|
||||||
|
|
||||||
|
|
||||||
|
def edit_field(std, label, value, field_type):
|
||||||
|
"""Inline edit a field value. Returns new value or None if cancelled."""
|
||||||
|
h, w = std.getmaxyx()
|
||||||
|
prompt = f" {label}: "
|
||||||
|
# clear a couple lines
|
||||||
|
for y in range(2):
|
||||||
|
std.addstr(h // 2 + y, 0, " " * w)
|
||||||
|
|
||||||
|
std.addstr(h // 2, 0, prompt)
|
||||||
|
# pre-fill with current value
|
||||||
|
start = len(prompt)
|
||||||
|
curses.echo()
|
||||||
|
curses.curs_set(1)
|
||||||
|
raw = std.getstr(h // 2, start, w - start - 2).decode("utf-8", errors="replace").strip()
|
||||||
|
curses.noecho()
|
||||||
|
curses.curs_set(0)
|
||||||
|
if not raw:
|
||||||
|
# keep old value
|
||||||
|
return value
|
||||||
|
if field_type == bool:
|
||||||
|
return raw.lower() in ("1", "true", "yes", "y")
|
||||||
|
if field_type == int:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def run_config_tui(std, cfg: AppConfig) -> AppConfig | None:
|
||||||
|
curses.curs_set(0)
|
||||||
|
curses.use_default_colors()
|
||||||
|
h, w = std.getmaxyx()
|
||||||
|
if h < 12 or w < 50:
|
||||||
|
std.addstr(0, 0, "Terminal too small (min 50x12)")
|
||||||
|
std.refresh()
|
||||||
|
std.getch()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# section list on the left
|
||||||
|
section_names = [s[1] for s in SECTION_FIELDS] # display names
|
||||||
|
section_data = [(s[0], s[2]) for s in SECTION_FIELDS] # (attr_name, fields)
|
||||||
|
|
||||||
|
sel_section = 0
|
||||||
|
sel_field = 0
|
||||||
|
top_section = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
std.erase()
|
||||||
|
h, w = std.getmaxyx()
|
||||||
|
left_w = min(20, w // 3)
|
||||||
|
|
||||||
|
# draw sections
|
||||||
|
sname = section_names[sel_section]
|
||||||
|
draw_menu(std, section_names, sel_section, top_section, h, left_w, "Sections")
|
||||||
|
|
||||||
|
# draw field list on the right
|
||||||
|
sec_attr, fields = section_data[sel_section]
|
||||||
|
current_obj = getattr(cfg, sec_attr)
|
||||||
|
field_labels = [f"{f[1]}: {getattr(current_obj, f[0])}" for f in fields]
|
||||||
|
draw_menu(std, field_labels, sel_field, 0, h, w - left_w - 1, sname)
|
||||||
|
|
||||||
|
key = std.getch()
|
||||||
|
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
sel_field = max(0, sel_field - 1)
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
sel_field = min(len(fields) - 1, sel_field + 1)
|
||||||
|
elif key == curses.KEY_LEFT:
|
||||||
|
sel_section = max(0, sel_section - 1)
|
||||||
|
sel_field = 0
|
||||||
|
top_section = 0
|
||||||
|
elif key == curses.KEY_RIGHT:
|
||||||
|
sel_section = min(len(section_names) - 1, sel_section + 1)
|
||||||
|
sel_field = 0
|
||||||
|
top_section = 0
|
||||||
|
elif key == ord("\n") or key == ord(" "):
|
||||||
|
# edit selected field
|
||||||
|
field_name, field_label, field_type, _ = fields[sel_field]
|
||||||
|
old = getattr(current_obj, field_name)
|
||||||
|
new = edit_field(std, field_label, old, field_type)
|
||||||
|
if new is not None:
|
||||||
|
setattr(current_obj, field_name, new)
|
||||||
|
elif key == ord("\t") or key == ord("s") or key == ord("S"):
|
||||||
|
# Save
|
||||||
|
save(cfg)
|
||||||
|
return cfg
|
||||||
|
elif key == ord("q") or key == 27: # ESC
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main_std(std):
|
||||||
|
cfg = load()
|
||||||
|
result = run_config_tui(std, cfg)
|
||||||
|
if result:
|
||||||
|
save(result)
|
||||||
|
msg = " ✓ Config saved!"
|
||||||
|
else:
|
||||||
|
msg = " ✗ Cancelled."
|
||||||
|
h, w = std.getmaxyx()
|
||||||
|
std.addstr(h - 1, 0, msg.ljust(w - 1))
|
||||||
|
std.refresh()
|
||||||
|
std.getch()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
curses.wrapper(main_std)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue