ltcai 0.1.31 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -193
- package/docs/CHANGELOG.md +44 -0
- package/latticeai/__init__.py +1 -0
- package/latticeai/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/api/__init__.py +1 -0
- package/latticeai/api/__pycache__/admin.cpython-314.pyc +0 -0
- package/latticeai/api/__pycache__/auth.cpython-314.pyc +0 -0
- package/latticeai/api/admin.py +187 -0
- package/latticeai/api/auth.py +233 -0
- package/latticeai/core/__init__.py +1 -0
- package/latticeai/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/audit.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/security.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/sessions.cpython-314.pyc +0 -0
- package/latticeai/core/audit.py +245 -0
- package/latticeai/core/security.py +131 -0
- package/latticeai/core/sessions.py +72 -0
- package/package.json +2 -1
- package/server.py +94 -719
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Password hashing, rate limiting, IP detection, file-magic validation."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hash_password(password: str) -> str:
|
|
15
|
+
salt = secrets.token_hex(16)
|
|
16
|
+
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
17
|
+
return f"{salt}:{key.hex()}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
21
|
+
try:
|
|
22
|
+
salt, key_hex = hashed.split(":", 1)
|
|
23
|
+
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
24
|
+
return secrets.compare_digest(key.hex(), key_hex)
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def host_is_loopback(host: str) -> bool:
|
|
30
|
+
if host in {"localhost", "127.0.0.1", "::1"}:
|
|
31
|
+
return True
|
|
32
|
+
try:
|
|
33
|
+
return ipaddress.ip_address(host).is_loopback
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def client_ip(request) -> str:
|
|
39
|
+
for header in ("CF-Connecting-IP", "X-Forwarded-For"):
|
|
40
|
+
val = request.headers.get(header)
|
|
41
|
+
if val:
|
|
42
|
+
return val.split(",")[0].strip()
|
|
43
|
+
return request.client.host if request.client else "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_FILE_MAGIC: Dict[str, List[bytes]] = {
|
|
47
|
+
".pdf": [b"%PDF-"],
|
|
48
|
+
".docx": [b"PK\x03\x04"],
|
|
49
|
+
".xlsx": [b"PK\x03\x04"],
|
|
50
|
+
".pptx": [b"PK\x03\x04"],
|
|
51
|
+
".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
|
52
|
+
".png": [b"\x89PNG\r\n\x1a\n"],
|
|
53
|
+
".jpg": [b"\xff\xd8\xff"],
|
|
54
|
+
".jpeg": [b"\xff\xd8\xff"],
|
|
55
|
+
".gif": [b"GIF87a", b"GIF89a"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bytes_match_extension(data: bytes, ext: str) -> bool:
|
|
60
|
+
ext = (ext or "").lower()
|
|
61
|
+
signatures = _FILE_MAGIC.get(ext)
|
|
62
|
+
if not signatures:
|
|
63
|
+
return True
|
|
64
|
+
head = data[:16]
|
|
65
|
+
return any(head.startswith(sig) for sig in signatures)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def redact_secret_text(text: str) -> str:
|
|
69
|
+
if not text:
|
|
70
|
+
return ""
|
|
71
|
+
patterns = [
|
|
72
|
+
r"(?i)(api[_ -]?key|secret|token|password|passwd)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{12,})['\"]?",
|
|
73
|
+
r"\b(sk-[A-Za-z0-9_\-]{16,})\b",
|
|
74
|
+
r"\b(xai-[A-Za-z0-9_\-]{16,})\b",
|
|
75
|
+
r"\b(gsk_[A-Za-z0-9_\-]{16,})\b",
|
|
76
|
+
]
|
|
77
|
+
redacted = str(text)
|
|
78
|
+
for pattern in patterns:
|
|
79
|
+
redacted = re.sub(pattern, lambda m: f"{m.group(1)}=[REDACTED]" if len(m.groups()) > 1 else "[REDACTED]", redacted)
|
|
80
|
+
return redacted
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── IP-based rate limiting (registration / login) ────────────────────────────
|
|
84
|
+
_ip_rate_windows: dict = {}
|
|
85
|
+
_ip_rate_lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_ip_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
|
|
89
|
+
key = (ip, action)
|
|
90
|
+
now = time.time()
|
|
91
|
+
cutoff = now - window_secs
|
|
92
|
+
with _ip_rate_lock:
|
|
93
|
+
calls = [t for t in _ip_rate_windows.get(key, []) if t > cutoff]
|
|
94
|
+
if len(calls) >= max_calls:
|
|
95
|
+
raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
|
|
96
|
+
calls.append(now)
|
|
97
|
+
_ip_rate_windows[key] = calls
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Per-user token-bucket rate limiting ──────────────────────────────────────
|
|
101
|
+
_RATE_LIMITS = {
|
|
102
|
+
"chat": (30, 0.5),
|
|
103
|
+
"agent": (10, 0.1),
|
|
104
|
+
"upload": (20, 0.2),
|
|
105
|
+
}
|
|
106
|
+
_rate_buckets: Dict[str, Dict[str, float]] = {}
|
|
107
|
+
_user_rate_lock = threading.Lock()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def enforce_rate_limit(email: str, bucket_key: str, *, enabled: bool = True) -> None:
|
|
111
|
+
if not enabled or not email:
|
|
112
|
+
return
|
|
113
|
+
cap, refill = _RATE_LIMITS.get(bucket_key, (60, 1.0))
|
|
114
|
+
key = f"{email}:{bucket_key}"
|
|
115
|
+
now = time.time()
|
|
116
|
+
with _user_rate_lock:
|
|
117
|
+
bucket = _rate_buckets.get(key)
|
|
118
|
+
if bucket is None:
|
|
119
|
+
_rate_buckets[key] = {"tokens": cap - 1, "ts": now}
|
|
120
|
+
return
|
|
121
|
+
elapsed = now - bucket["ts"]
|
|
122
|
+
bucket["tokens"] = min(cap, bucket["tokens"] + elapsed * refill)
|
|
123
|
+
bucket["ts"] = now
|
|
124
|
+
if bucket["tokens"] < 1:
|
|
125
|
+
retry_after = max(1, int((1 - bucket["tokens"]) / refill))
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=429,
|
|
128
|
+
detail=f"Rate limit exceeded for {bucket_key}. Retry after {retry_after}s.",
|
|
129
|
+
headers={"Retry-After": str(retry_after)},
|
|
130
|
+
)
|
|
131
|
+
bucket["tokens"] -= 1
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""File-backed session store with sliding-window TTL."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
SESSION_TTL = 60 * 60 * 24 # 24 hours
|
|
13
|
+
SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
14
|
+
_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sessions_file(data_dir: Optional[Path] = None) -> Path:
|
|
18
|
+
d = data_dir or Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d / "sessions.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
|
|
24
|
+
try:
|
|
25
|
+
f = _sessions_file(data_dir)
|
|
26
|
+
if f.exists():
|
|
27
|
+
raw = json.loads(f.read_text())
|
|
28
|
+
return {k: tuple(v) for k, v in raw.items()}
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logging.warning("load_sessions failed (starting empty): %s", e)
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None) -> None:
|
|
35
|
+
try:
|
|
36
|
+
_sessions_file(data_dir).write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logging.warning("persist_sessions failed: %s", e)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionStore:
|
|
42
|
+
def __init__(self, data_dir: Optional[Path] = None):
|
|
43
|
+
self._data_dir = data_dir
|
|
44
|
+
self._sessions: Dict[str, tuple] = load_sessions(data_dir)
|
|
45
|
+
|
|
46
|
+
def create(self, email: str) -> str:
|
|
47
|
+
token = secrets.token_urlsafe(32)
|
|
48
|
+
with _lock:
|
|
49
|
+
self._sessions[token] = (email, time.time())
|
|
50
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
51
|
+
return token
|
|
52
|
+
|
|
53
|
+
def get_email(self, token: str) -> Optional[str]:
|
|
54
|
+
now = time.time()
|
|
55
|
+
with _lock:
|
|
56
|
+
entry = self._sessions.get(token)
|
|
57
|
+
if entry is None:
|
|
58
|
+
return None
|
|
59
|
+
email, created_at = entry
|
|
60
|
+
if now - created_at > SESSION_TTL:
|
|
61
|
+
self._sessions.pop(token, None)
|
|
62
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
63
|
+
return None
|
|
64
|
+
if now - created_at > SESSION_REFRESH_THRESHOLD:
|
|
65
|
+
self._sessions[token] = (email, now)
|
|
66
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
67
|
+
return email
|
|
68
|
+
|
|
69
|
+
def invalidate(self, token: str) -> None:
|
|
70
|
+
with _lock:
|
|
71
|
+
self._sessions.pop(token, None)
|
|
72
|
+
persist_sessions(self._sessions, self._data_dir)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"tools.py",
|
|
55
55
|
"codex_telegram_bot.py",
|
|
56
56
|
"mcp_registry.py",
|
|
57
|
+
"latticeai/",
|
|
57
58
|
"skills/",
|
|
58
59
|
"static/account.html",
|
|
59
60
|
"static/chat.html",
|