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/", 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/") def serve_svg(filename): """SVG-иконки — редко меняются, долгий кеш.""" return _serve_with_cache("SVG", filename, STATIC_MONTH) @app.route("/static/leaflet/") def serve_leaflet(filename): """Leaflet (версионирован по содержимому) — immutable.""" return _serve_with_cache("static/leaflet", filename, IMMUTABLE_YEAR) @app.route("/static/xterm/") def serve_xterm(filename): """xterm.js — immutable.""" return _serve_with_cache("static/xterm", filename, IMMUTABLE_YEAR) @app.route("/static/js/") def serve_static_js(filename): """app.js, модули — SWR: всегда проверяем обновление, но отдаём из кеша если не изменилось.""" return _serve_with_cache("static/js", filename, SHORT_REVALIDATE) @app.route("/static/css/") 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///.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///.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