Files
WebAisMap/routes.py
T
Grigo 03075f1ef1 Initial import: WebAisMap
Closes TG-4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 07:56:45 +03:00

938 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import time
import socket
import subprocess
import json
import threading
import hashlib
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from urllib.error import URLError, HTTPError
from flask import Flask, jsonify, render_template, send_from_directory, send_file, request, Response
try:
from flask_sock import Sock
except ImportError:
Sock = None
try:
from websocket import create_connection as _ws_create_connection
from websocket import WebSocketConnectionClosedException as _WsClosed
except ImportError:
_ws_create_connection = None
_WsClosed = Exception
from state import get_cpu_usage_percent
from network_manager import (
load_network_config, save_network_config, get_current_network_info,
switch_network_mode, network_config_lock, NETWORK_DEFAULTS,
)
from terminal import terminal_session
from transponder import (
load_transponder_config,
normalize_transponder_config,
save_transponder_config,
merge_transponder_request,
build_preview,
send_transmission,
build_slot_udp_payload,
is_aistx_phy_available,
parse_nrzi_hex,
send_raw_nrzi_packet,
tx_gpio_pulse,
)
app = Flask(__name__)
sock = Sock(app) if Sock else None
# ==================== ais_hub upstream ====================
AIS_HUB_URL = os.environ.get("AIS_HUB_URL", "http://127.0.0.1:8081").rstrip("/")
def _ais_hub_ws_url():
base = AIS_HUB_URL
if base.startswith("https://"):
return "wss://" + base[len("https://"):] + "/ws"
if base.startswith("http://"):
return "ws://" + base[len("http://"):] + "/ws"
return "ws://" + base + "/ws"
# ==================== helpers ====================
def _append_client_log_line(line: str):
"""Append a single line to client_errors.log (best-effort)."""
try:
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
with open(path, "a", encoding="utf-8") as f:
f.write(line.rstrip("\n") + "\n")
except Exception:
pass
# ==================== WebSocket terminal ====================
if sock is not None and os.name == "posix":
@sock.route("/ws/terminal")
def ws_terminal(ws):
terminal_session(ws)
# ==================== WebSocket proxy to ais_hub /ws ====================
if sock is not None:
@sock.route("/ws")
def ws_ais_hub_proxy(ws):
"""Прозрачный WS-прокси к ais_hub /ws (localhost:8081)."""
if _ws_create_connection is None:
# Не закрываем WS сразу: иначе браузер будет переподключаться в цикле и шуметь в консоли.
# Держим соединение открытым и периодически напоминаем про отсутствующую зависимость.
while True:
try:
ws.send(json.dumps({"type": "error", "ts": time.time(),
"data": {"error": "websocket-client not installed on server"}}))
except Exception:
return
try:
time.sleep(5)
except Exception:
return
upstream_url = _ais_hub_ws_url()
# Если ais_hub временно недоступен, не рвём соединение с браузером:
# держим WS открытым и периодически ретраим подключение к upstream.
upstream = None
backoff = 1.0
while upstream is None:
try:
upstream = _ws_create_connection(upstream_url, timeout=5)
break
except Exception as e:
try:
ws.send(json.dumps({"type": "error", "ts": time.time(),
"data": {"error": f"ais_hub unreachable: {e}"}}))
except Exception:
# Клиент ушёл — дальше ретраить нет смысла.
return
try:
time.sleep(backoff)
except Exception:
return
backoff = min(backoff * 2.0, 10.0)
stop = threading.Event()
def upstream_to_client():
try:
while not stop.is_set():
try:
msg = upstream.recv()
except _WsClosed:
break
except Exception:
break
if msg is None or msg == "":
break
if isinstance(msg, bytes):
try:
msg = msg.decode("utf-8", errors="ignore")
except Exception:
continue
try:
ws.send(msg)
except Exception:
break
finally:
stop.set()
t = threading.Thread(target=upstream_to_client, daemon=True)
t.start()
try:
while not stop.is_set():
try:
# flask_sock/simple_websocket have slightly different receive() signatures
# across versions; some don't support timeout=, and some raise on timeout.
try:
data = ws.receive(timeout=30)
except TypeError:
data = ws.receive()
except Exception as e:
# Don't tear down the whole proxy on a benign receive timeout.
if "timeout" in str(e).lower():
continue
break
if data is None:
break
try:
if isinstance(data, bytes):
upstream.send_binary(data)
else:
upstream.send(data)
except Exception:
break
finally:
stop.set()
try:
upstream.close()
except Exception:
pass
# ==================== Pages ====================
@app.route("/")
def index():
return render_template("index.html")
@app.route("/cert")
def cert_install_page():
return render_template("cert.html")
# ==================== REST proxy to ais_hub /api/v1/* ====================
_HOP_HEADERS = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "content-encoding",
"content-length", "host",
}
@app.route("/api/v1/<path:rest>", methods=["GET", "POST", "PUT", "DELETE"])
def proxy_ais_hub(rest):
"""Прозрачный прокси REST /api/v1/* на ais_hub (127.0.0.1:8081)."""
url = f"{AIS_HUB_URL}/api/v1/{rest}"
qs = request.query_string.decode("utf-8") if request.query_string else ""
if qs:
url = f"{url}?{qs}"
method = request.method.upper()
body = None
if method in ("POST", "PUT"):
body = request.get_data() or b""
headers = {}
ct = request.headers.get("Content-Type")
if ct:
headers["Content-Type"] = ct
req = Request(url, data=body, method=method, headers=headers)
timeout = 10.0 if method in ("POST", "PUT") else 5.0
try:
resp = urlopen(req, timeout=timeout)
payload = resp.read()
status = getattr(resp, "status", 200) or 200
out_headers = {}
for k, v in resp.headers.items():
if k.lower() in _HOP_HEADERS:
continue
out_headers[k] = v
out_headers.setdefault("Cache-Control", "no-store")
return Response(payload, status=status, headers=out_headers)
except HTTPError as e:
try:
payload = e.read()
except Exception:
payload = b""
out_headers = {}
try:
for k, v in e.headers.items():
if k.lower() in _HOP_HEADERS:
continue
out_headers[k] = v
except Exception:
pass
out_headers.setdefault("Cache-Control", "no-store")
return Response(payload, status=e.code, headers=out_headers)
except URLError as e:
return jsonify({"error": f"ais_hub unreachable: {e.reason}"}), 503
except Exception as e:
return jsonify({"error": f"proxy error: {e}"}), 502
# ==================== API ====================
@app.route("/api/terminal")
def api_terminal():
"""Доступность веб-консоли (PTY + WebSocket)."""
return jsonify({"pty": os.name == "posix", "ws": sock is not None})
@app.route("/api/version")
def api_version():
"""Версия/сборка для отладки кэша фронта и деплоя."""
try:
here = os.path.dirname(__file__)
def _mtime(p):
try:
return int(os.path.getmtime(os.path.join(here, p)))
except Exception:
return None
return jsonify({
"server_time": int(time.time()),
"routes_py_mtime": _mtime("routes.py"),
"app_js_mtime": _mtime(os.path.join("static", "js", "app.js")),
"index_html_mtime": _mtime(os.path.join("templates", "index.html")),
})
except Exception as e:
return jsonify({"server_time": int(time.time()), "error": str(e)})
@app.route("/api/sysinfo")
def api_sysinfo():
"""Локальная система: CPU %, температура, память, uptime. ais_hub этого не знает."""
now = int(time.time())
out = {"server_now": now}
try:
with open("/proc/uptime", "r") as f:
out["sys_uptime"] = int(float(f.read().split()[0]))
except Exception:
out["sys_uptime"] = None
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
out["cpu_temp"] = round(int(f.read().strip()) / 1000.0, 1)
except Exception:
out["cpu_temp"] = None
out["cpu_percent"] = get_cpu_usage_percent()
try:
mi = {}
with open("/proc/meminfo", "r") as f:
for line in f:
k, v = line.split(":", 1)
mi[k.strip()] = int(v.strip().split()[0])
total = mi.get("MemTotal", 0)
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
out["mem_total_mb"] = round(total / 1024)
out["mem_used_mb"] = round((total - avail) / 1024)
out["mem_pct"] = round((total - avail) / total * 100, 1) if total else 0
except Exception:
out["mem_total_mb"] = None
out["mem_used_mb"] = None
out["mem_pct"] = None
return jsonify(out)
@app.route("/api/client_log", methods=["POST"])
def api_client_log():
"""
Приём логов/ошибок из браузера (front-end).
Пишет в client_errors.log рядом с routes.py.
"""
try:
data = request.get_json(force=True, silent=True) or {}
except Exception:
data = {}
level = str(data.get("level") or "info").lower()
msg = str(data.get("msg") or "")
ctx = data.get("ctx")
ts = data.get("ts")
url = data.get("url")
ua = data.get("ua")
try:
now = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{now}] level={level} msg={msg} url={url} ua={ua} ctx={ctx} ts={ts}"
_append_client_log_line(line)
except Exception:
pass
return jsonify({"ok": True})
@app.route("/api/client_log_tail")
def api_client_log_tail():
"""Хвост client_errors.log для диагностики без SSH."""
try:
n = request.args.get("n", "200")
try:
n = int(n)
except Exception:
n = 200
n = max(1, min(2000, n))
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
if not os.path.exists(path):
return jsonify({"ok": True, "exists": False, "lines": []})
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
tail = [ln.rstrip("\n") for ln in lines[-n:]]
return jsonify({"ok": True, "exists": True, "lines": tail})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/transponder", methods=["GET"])
def api_transponder_get():
"""Настройки Class B. ownship тянется фронтом отдельно через /api/v1/ownship."""
try:
cfg = normalize_transponder_config(load_transponder_config())
return jsonify(
{
"ok": True,
"config": cfg,
"aistx_phy_available": is_aistx_phy_available(),
}
)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
def _fetch_ownship_from_hub():
"""Best-effort: последний GPS-fix из ais_hub. Возвращает dict (возможно пустой)."""
try:
resp = urlopen(f"{AIS_HUB_URL}/api/v1/ownship", timeout=2.0)
raw = resp.read()
own = json.loads(raw.decode("utf-8", errors="ignore"))
if not isinstance(own, dict):
return {}
# Адаптируем имена полей к тем, что ожидает transponder (course/speed/...).
return {
"lat": own.get("lat"),
"lon": own.get("lon"),
"course": own.get("cog"),
"speed": own.get("sog"),
"heading": None,
"timestamp": own.get("ts"),
"satellites": own.get("sats"),
"fix_quality": own.get("fix_quality"),
}
except Exception:
return {}
@app.route("/api/transponder", methods=["POST"])
def api_transponder_save():
"""Сохранить настройки транспондера в transponder_config.json."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
merged = merge_transponder_request(base, data)
saved = save_transponder_config(merged)
return jsonify({"ok": True, "config": saved})
@app.route("/api/transponder/preview", methods=["POST"])
def api_transponder_preview():
"""NRZI hex для типов 18/19/24 без отправки (отправка — 127.0.0.1:6010)."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
cfg = merge_transponder_request(base, data)
own = _fetch_ownship_from_hub()
try:
prev = build_preview(cfg, own)
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 503
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
prev.pop("dictionaries", None)
return jsonify({"ok": True, "preview": prev})
@app.route("/api/transponder/send", methods=["POST"])
def api_transponder_send():
"""NRZI по UDP на 127.0.0.1:6010 с заголовком канал+слот (как тест слота)."""
data = request.get_json(force=True) or {}
which = data.get("which")
if which not in ("18", "19", "24A", "24B", "broadcast"):
return jsonify(
{"ok": False, "error": "which must be 18, 19, 24A, 24B, broadcast"}
), 400
cfg_body = {k: v for k, v in data.items() if k != "which"}
base = load_transponder_config()
cfg = merge_transponder_request(base, cfg_body)
own = _fetch_ownship_from_hub()
try:
result = send_transmission(cfg, own, which)
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 503
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "result": result})
@app.route("/api/transponder/gpio_pulse", methods=["POST"])
def api_transponder_gpio_pulse():
"""Один импульс TX (GPIO), без UDP. Настройки скрипта — из тела запроса или transponder_config."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
cfg = merge_transponder_request(base, data)
pulse = tx_gpio_pulse(cfg, after_udp=False)
if not pulse.get("ok"):
err = pulse.get("error") or pulse.get("stderr") or "GPIO pulse failed"
return jsonify({"ok": False, "error": err, "pulse": pulse}), 400
return jsonify({"ok": True, "pulse": pulse})
@app.route("/api/transponder/send_raw", methods=["POST"])
def api_transponder_send_raw():
"""Сырой NRZI (hex) + канал + слот → UDP 127.0.0.1:6010."""
data = request.get_json(force=True) or {}
hx = data.get("nrzi_hex") or data.get("hex")
if not isinstance(hx, str) or not hx.strip():
return jsonify({"ok": False, "error": "nrzi_hex (hex строка) обязателен"}), 400
channel = data.get("channel", "A")
try:
slot = int(data.get("slot", 0))
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "slot must be integer 0..2249"}), 400
if channel not in ("A", "B"):
return jsonify({"ok": False, "error": "channel must be A or B"}), 400
if slot < 0 or slot > 2249:
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
try:
body = parse_nrzi_hex(hx)
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
if not body:
return jsonify({"ok": False, "error": "пустой payload после hex"}), 400
cfg_body = {
k: v
for k, v in data.items()
if k not in ("nrzi_hex", "hex", "channel", "slot")
}
base = load_transponder_config()
cfg = merge_transponder_request(base, cfg_body)
try:
result = send_raw_nrzi_packet(channel, slot, body, cfg=cfg)
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "result": result})
# Аппаратно проверенная NRZI-последовательность для «Тест слота».
TEST_AIS_NRZI = bytes([
102, 102, 102, 254, 149, 61, 224, 94, 245, 171, 174, 169,
74, 84, 87, 105, 51, 82, 202, 166, 141, 99, 170, 170,
170, 253, 236, 63, 170, 170, 170, 170,
])
@app.route("/api/send_test_slot", methods=["POST"])
def api_send_test_slot():
"""Отправляет тестовую датаграмму AIS NRZI в указанный слот/канал (UDP 127.0.0.1:6010)."""
data = request.get_json(force=True)
if not data:
return jsonify({"ok": False, "error": "Empty payload"}), 400
channel = data.get("channel")
slot = data.get("slot")
if channel not in ("A", "B"):
return jsonify({"ok": False, "error": "channel must be 'A' or 'B'"}), 400
if not isinstance(slot, int) or slot < 0 or slot > 2249:
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
dest = ("127.0.0.1", 6010)
datagram = build_slot_udp_payload(channel, slot, TEST_AIS_NRZI)
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(datagram, dest)
s.close()
return jsonify({"ok": True, "dest": f"{dest[0]}:{dest[1]}", "size": len(datagram)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ==================== Network API ====================
@app.route("/api/network", methods=["GET"])
def api_network_get():
"""Returns current network configuration and live status."""
with network_config_lock:
cfg = load_network_config()
live = get_current_network_info()
return jsonify({"config": cfg, "live": live})
@app.route("/api/network", methods=["POST"])
def api_network_save():
"""Saves network configuration (does NOT switch mode immediately)."""
data = request.get_json(force=True)
if not data:
return jsonify({"ok": False, "error": "Empty payload"}), 400
with network_config_lock:
cfg = load_network_config()
for key in NETWORK_DEFAULTS:
if key in data:
cfg[key] = data[key]
save_network_config(cfg)
return jsonify({"ok": True, "config": cfg})
@app.route("/api/network/switch", methods=["POST"])
def api_network_switch():
"""Switches network mode to AP or WiFi by running the shell script."""
data = request.get_json(force=True)
target = data.get("mode") if data else None
if target not in ("ap", "wifi"):
return jsonify({"ok": False, "error": "Provide 'mode': 'ap' or 'wifi'"}), 400
with network_config_lock:
cfg = load_network_config()
for key in NETWORK_DEFAULTS:
if key in data and key != "mode":
cfg[key] = data[key]
save_network_config(cfg)
result = switch_network_mode(target)
return jsonify(result)
AIS_MINI_CONF = "/ais-mini.conf"
AIS_MINI_SERVICE = "aisMini.service"
AIS_HUB_CONF = "/opt/aishub/config/config.yaml"
AIS_HUB_SERVICE = "ais_hub.service"
@app.route("/api/config", methods=["GET"])
def api_config_get():
"""Returns the content of the AIS-catcher Mini configuration file."""
try:
with open(AIS_MINI_CONF, "r") as f:
text = f.read()
return jsonify({"ok": True, "text": text})
except FileNotFoundError:
return jsonify({"ok": False, "error": f"File not found: {AIS_MINI_CONF}"}), 404
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/config", methods=["POST"])
def api_config_save():
"""Saves the AIS-catcher Mini configuration file."""
data = request.get_json(force=True)
text = data.get("text") if data else None
if text is None:
return jsonify({"ok": False, "error": "No 'text' field"}), 400
try:
with open(AIS_MINI_CONF, "w") as f:
f.write(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/restart", methods=["POST"])
def api_service_restart():
"""Restarts the aisMini.service via systemctl."""
try:
result = subprocess.run(
["systemctl", "restart", AIS_MINI_SERVICE],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return jsonify({"ok": True, "message": f"{AIS_MINI_SERVICE} restarted"})
else:
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Restart timed out"}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/status", methods=["GET"])
def api_service_status():
"""Returns the status of aisMini.service."""
try:
result = subprocess.run(
["systemctl", "is-active", AIS_MINI_SERVICE],
capture_output=True, text=True, timeout=5,
)
state = result.stdout.strip()
return jsonify({"ok": True, "state": state})
except Exception as e:
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
@app.route("/api/config/aishub", methods=["GET"])
def api_aishub_config_get():
"""Returns the content of the ais_hub configuration YAML."""
try:
with open(AIS_HUB_CONF, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
return jsonify({"ok": True, "text": text})
except FileNotFoundError:
return jsonify({"ok": False, "error": f"File not found: {AIS_HUB_CONF}"}), 404
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/config/aishub", methods=["POST"])
def api_aishub_config_save():
"""Saves the ais_hub configuration YAML."""
data = request.get_json(force=True)
text = data.get("text") if data else None
if text is None:
return jsonify({"ok": False, "error": "No 'text' field"}), 400
try:
with open(AIS_HUB_CONF, "w", encoding="utf-8") as f:
f.write(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/aishub/status", methods=["GET"])
def api_aishub_service_status():
"""Returns the status of ais_hub.service."""
try:
result = subprocess.run(
["systemctl", "is-active", AIS_HUB_SERVICE],
capture_output=True, text=True, timeout=5,
)
state = result.stdout.strip()
return jsonify({"ok": True, "state": state})
except Exception as e:
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
@app.route("/api/service/aishub/restart", methods=["POST"])
def api_aishub_service_restart():
"""Restarts ais_hub.service via systemctl."""
try:
result = subprocess.run(
["systemctl", "restart", AIS_HUB_SERVICE],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return jsonify({"ok": True, "message": f"{AIS_HUB_SERVICE} restarted"})
else:
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Restart timed out"}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/network/scan", methods=["GET"])
def api_network_scan():
"""Scans for available WiFi networks (Linux/iw only)."""
cfg = load_network_config()
iface = cfg.get("iface", "wlan0")
try:
out = subprocess.check_output(
["iw", "dev", iface, "scan", "ap-force"],
timeout=15, stderr=subprocess.DEVNULL,
).decode()
networks = []
current = {}
for line in out.splitlines():
line = line.strip()
if line.startswith("BSS "):
if current.get("ssid"):
networks.append(current)
current = {"bssid": line.split()[1].rstrip("("), "ssid": "", "signal": None}
elif line.startswith("SSID:"):
current["ssid"] = line.split(":", 1)[1].strip()
elif line.startswith("signal:"):
try:
current["signal"] = float(line.split(":")[1].strip().split()[0])
except (ValueError, IndexError):
pass
if current.get("ssid"):
networks.append(current)
seen = set()
unique = []
for n in networks:
if n["ssid"] not in seen:
seen.add(n["ssid"])
unique.append(n)
unique.sort(key=lambda x: x.get("signal") or -999, reverse=True)
return jsonify({"ok": True, "networks": unique})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Scan timed out"})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "networks": []})
# ==================== Certificate downloads ====================
@app.route("/ca.pem")
def download_ca():
"""Отдаёт корневой CA-сертификат для установки на клиентские устройства."""
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
if not os.path.exists(ca_path):
return "CA certificate not generated", 404
return send_file(ca_path, mimetype="application/x-pem-file",
as_attachment=True, download_name="AISMap_CA.pem")
@app.route("/ca.crt")
def download_ca_crt():
"""Тот же CA, но с расширением .crt — Android открывает установщик автоматически."""
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
if not os.path.exists(ca_path):
return "CA certificate not generated", 404
return send_file(ca_path, mimetype="application/x-x509-ca-cert",
as_attachment=True, download_name="AISMap_CA.crt")
# ==================== Static files with caching ====================
IMMUTABLE_YEAR = "public, max-age=31536000, immutable"
STATIC_MONTH = "public, max-age=2592000"
SHORT_REVALIDATE = "public, max-age=60, must-revalidate"
def _etag_for_path(path):
try:
st = os.stat(path)
h = hashlib.md5(f"{st.st_mtime_ns}:{st.st_size}".encode("ascii")).hexdigest()
return f'W/"{h}"'
except Exception:
return None
def _serve_with_cache(directory, filename, cache_control):
"""send_from_directory + заголовок Cache-Control + ETag/304."""
abs_path = os.path.join(directory, filename)
etag = _etag_for_path(abs_path)
inm = request.headers.get("If-None-Match")
if etag and inm and inm == etag:
resp = Response(status=304)
resp.headers["ETag"] = etag
resp.headers["Cache-Control"] = cache_control
return resp
resp = send_from_directory(directory, filename)
resp.headers["Cache-Control"] = cache_control
if etag:
resp.headers["ETag"] = etag
return resp
@app.route("/svg/<path:filename>")
def serve_svg(filename):
"""SVG-иконки — редко меняются, долгий кеш."""
return _serve_with_cache("SVG", filename, STATIC_MONTH)
@app.route("/static/leaflet/<path:filename>")
def serve_leaflet(filename):
"""Leaflet (версионирован по содержимому) — immutable."""
return _serve_with_cache("static/leaflet", filename, IMMUTABLE_YEAR)
@app.route("/static/xterm/<path:filename>")
def serve_xterm(filename):
"""xterm.js — immutable."""
return _serve_with_cache("static/xterm", filename, IMMUTABLE_YEAR)
@app.route("/static/js/<path:filename>")
def serve_static_js(filename):
"""app.js, модули — SWR: всегда проверяем обновление, но отдаём из кеша если не изменилось."""
return _serve_with_cache("static/js", filename, SHORT_REVALIDATE)
@app.route("/static/css/<path:filename>")
def serve_static_css(filename):
return _serve_with_cache("static/css", filename, SHORT_REVALIDATE)
@app.route("/sw.js")
def serve_sw():
"""Service Worker должен быть в корне для scope '/'."""
path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
if not os.path.exists(path):
return "sw.js not found", 404
with open(path, "r", encoding="utf-8") as f:
body = f.read()
resp = Response(body, mimetype="application/javascript")
resp.headers["Cache-Control"] = "no-store"
resp.headers["Service-Worker-Allowed"] = "/"
return resp
VECTOR_TILE_URL = os.environ.get("AISMAP_VECTOR_TILE_URL", "http://127.0.0.1:8080").rstrip("/")
VECTOR_TILE_SERVICE = os.environ.get("AISMAP_VECTOR_TILE_SERVICE", "planet_small_z14").strip().strip("/")
@app.route("/vtiles/<int:z>/<int:x>/<int:y>.pbf")
def proxy_vector_tile(z, x, y):
"""Проксирует PBF-тайлы с локального векторного тайлсервера. immutable: тайл не меняется."""
try:
candidates = [
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/tiles/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/data/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
]
last_err = None
for url in candidates:
try:
resp = urlopen(url, timeout=5)
data = resp.read()
code = getattr(resp, "status", 200) or 200
headers = {
"Content-Type": resp.headers.get("Content-Type", "application/x-protobuf"),
"Cache-Control": IMMUTABLE_YEAR,
"Access-Control-Allow-Origin": "*",
"X-AISMap-Upstream": url,
}
ce = resp.headers.get("Content-Encoding")
if ce:
headers["Content-Encoding"] = ce
return data, code, headers
except HTTPError as e:
last_err = e
if getattr(e, "code", None) != 404:
raise
except URLError as e:
last_err = e
break
if isinstance(last_err, HTTPError) and getattr(last_err, "code", None) == 404:
return "Vector tile not found", 404
return "Vector tile upstream unavailable", 503
except (URLError, HTTPError):
return "Vector tile upstream error", 503
@app.route("/tiles/<int:z>/<int:x>/<int:y>.png")
def serve_tile(z, x, y):
"""Отдаёт растровые тайлы из static/tiles с вечным кешем (immutable)."""
max_tile = 2 ** z
if x < 0 or x >= max_tile or y < 0 or y >= max_tile:
return "Invalid tile coordinates", 404
rel_dir = os.path.join("static", "tiles", str(z), str(x))
tile_path = os.path.join(rel_dir, f"{y}.png")
if not os.path.exists(tile_path):
return "Tile not found", 404
etag = _etag_for_path(tile_path)
inm = request.headers.get("If-None-Match")
if etag and inm and inm == etag:
resp = Response(status=304)
resp.headers["ETag"] = etag
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
return resp
resp = send_from_directory(rel_dir, f"{y}.png")
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
if etag:
resp.headers["ETag"] = etag
return resp