aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2024-02-19 01:44:02 +0300
committerEvgeny Zinoviev <me@ch1p.io>2024-02-19 01:44:11 +0300
commit3741f7cf78a288e967415ccb6736c888a21c211b (patch)
treea48d8331c9936d6c108de4d0f9179a089b1e56e6
parentd79309e498cdc1358c81367ce2a93a5731e517d1 (diff)
web_kbn: almost completely ported lws to python
-rwxr-xr-xbin/relay_mqtt_http_proxy.py67
-rwxr-xr-xbin/sound_node.py2
-rwxr-xr-xbin/web_api.py22
-rw-r--r--bin/web_kbn.py580
-rw-r--r--doc/localwebsite.md7
-rw-r--r--include/py/homekit/config/config.py2
-rw-r--r--include/py/homekit/http/__init__.py3
-rw-r--r--include/py/homekit/http/http.py132
-rw-r--r--include/py/homekit/media/node_server.py10
-rw-r--r--include/py/homekit/openwrt/__init__.py9
-rw-r--r--include/py/homekit/openwrt/config.py14
-rw-r--r--include/py/homekit/openwrt/openwrt.py90
-rw-r--r--include/py/homekit/soundsensor/server.py11
-rw-r--r--include/py/homekit/util.py5
-rw-r--r--localwebsite/classes/MyOpenWrtUtils.php139
-rw-r--r--localwebsite/classes/MySimpleSocketClient.php90
-rw-r--r--localwebsite/classes/TemphumdClient.php41
-rw-r--r--localwebsite/classes/User.php11
-rw-r--r--localwebsite/classes/auth.php60
-rw-r--r--localwebsite/classes/config.php13
-rw-r--r--localwebsite/classes/users.php39
-rw-r--r--localwebsite/engine/debug.php355
-rw-r--r--localwebsite/engine/model.php243
-rw-r--r--localwebsite/engine/request_handler.php142
-rw-r--r--localwebsite/engine/router.php199
-rw-r--r--localwebsite/engine/tpl.php520
-rw-r--r--localwebsite/handlers/AuthHandler.php36
-rw-r--r--localwebsite/handlers/FakeRequestHandler.php20
-rw-r--r--localwebsite/handlers/MiscHandler.php40
-rw-r--r--localwebsite/handlers/ModemHandler.php130
-rw-r--r--localwebsite/handlers/RequestHandler.php52
-rw-r--r--localwebsite/htdocs/.htaccess6
-rw-r--r--localwebsite/htdocs/favicon.icobin1150 -> 0 bytes
-rw-r--r--localwebsite/htdocs/index.php43
-rw-r--r--localwebsite/templates-web/404.twig1
-rw-r--r--localwebsite/templates-web/auth.twig24
-rw-r--r--localwebsite/templates-web/bc.twig12
-rw-r--r--localwebsite/templates-web/footer.twig8
-rw-r--r--localwebsite/templates-web/header.twig15
-rw-r--r--localwebsite/templates-web/index.twig35
-rw-r--r--localwebsite/templates-web/routing_dhcp_page.twig11
-rw-r--r--localwebsite/templates-web/routing_header.twig23
-rw-r--r--localwebsite/templates-web/routing_ipsets_page.twig29
-rw-r--r--localwebsite/templates-web/routing_page.twig17
-rw-r--r--localwebsite/templates-web/sensors.twig15
-rw-r--r--web/kbn_assets/error_page.css30
-rw-r--r--web/kbn_templates/base.j22
-rw-r--r--web/kbn_templates/index.j26
-rw-r--r--web/kbn_templates/routing_dhcp.j214
-rw-r--r--web/kbn_templates/routing_header.j219
-rw-r--r--web/kbn_templates/routing_main.j219
-rw-r--r--web/kbn_templates/routing_rules.j230
52 files changed, 703 insertions, 2740 deletions
diff --git a/bin/relay_mqtt_http_proxy.py b/bin/relay_mqtt_http_proxy.py
index 23938e1..866ead3 100755
--- a/bin/relay_mqtt_http_proxy.py
+++ b/bin/relay_mqtt_http_proxy.py
@@ -2,6 +2,7 @@
import logging
import __py_include
+from aiohttp import web
from homekit import http
from homekit.config import config, AppConfigUnit
from homekit.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig
@@ -15,7 +16,6 @@ mqtt: Optional[MqttWrapper] = None
mqtt_nodes: dict[str, MqttNode] = {}
relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {}
relay_states: dict[str, MqttRelayState] = {}
-
mqtt_nodes_config = MqttNodesConfig()
@@ -67,41 +67,47 @@ def on_mqtt_message(node: MqttNode,
relay_states[node.id].update(**kwargs)
-class RelayMqttHttpProxy(http.HTTPServer):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.get('/relay/{id}/on', self.relay_on)
- self.get('/relay/{id}/off', self.relay_off)
- self.get('/relay/{id}/toggle', self.relay_toggle)
+# -=-=-=-=-=-=- #
+# Web interface #
+# -=-=-=-=-=-=- #
+
+routes = web.RouteTableDef()
+
+
+async def _relay_on_off(self,
+ enable: Optional[bool],
+ req: web.Request):
+ node_id = req.match_info['id']
+ node_secret = req.query['secret']
+
+ node = mqtt_nodes[node_id]
+ relay_module = relay_modules[node_id]
+
+ if enable is None:
+ if node_id in relay_states and relay_states[node_id].ever_updated:
+ cur_state = relay_states[node_id].enabled
+ else:
+ cur_state = False
+ enable = not cur_state
- async def _relay_on_off(self,
- enable: Optional[bool],
- req: http.Request):
- node_id = req.match_info['id']
- node_secret = req.query['secret']
+ node.secret = node_secret
+ relay_module.switchpower(enable)
+ return self.ok()
- node = mqtt_nodes[node_id]
- relay_module = relay_modules[node_id]
- if enable is None:
- if node_id in relay_states and relay_states[node_id].ever_updated:
- cur_state = relay_states[node_id].enabled
- else:
- cur_state = False
- enable = not cur_state
+@routes.get('/relay/{id}/on')
+async def relay_on(self, req: web.Request):
+ return await self._relay_on_off(True, req)
- node.secret = node_secret
- relay_module.switchpower(enable)
- return self.ok()
- async def relay_on(self, req: http.Request):
- return await self._relay_on_off(True, req)
+@routes.get('/relay/{id}/off')
+async def relay_off(self, req: web.Request):
+ return await self._relay_on_off(False, req)
- async def relay_off(self, req: http.Request):
- return await self._relay_on_off(False, req)
- async def relay_toggle(self, req: http.Request):
- return await self._relay_on_off(None, req)
+@routes.get('/relay/{id}/toggle')
+async def relay_toggle(self, req: web.Request):
+ return await self._relay_on_off(None, req)
if __name__ == '__main__':
@@ -127,8 +133,7 @@ if __name__ == '__main__':
mqtt.connect_and_loop(loop_forever=False)
- proxy = RelayMqttHttpProxy(config.app_config['listen_addr'])
try:
- proxy.run()
+ http.serve(config.app_config['listen_addr'], routes=routes)
except KeyboardInterrupt:
mqtt.disconnect()
diff --git a/bin/sound_node.py b/bin/sound_node.py
index 90e6997..4d0172c 100755
--- a/bin/sound_node.py
+++ b/bin/sound_node.py
@@ -17,7 +17,7 @@ from homekit import http
def _amixer_control_response(control):
info = amixer.get(control)
caps = amixer.get_caps(control)
- return http.ok({
+ return http.ajax_ok({
'caps': caps,
'info': info
})
diff --git a/bin/web_api.py b/bin/web_api.py
index d221838..1804b30 100755
--- a/bin/web_api.py
+++ b/bin/web_api.py
@@ -51,7 +51,7 @@ class WebAPIServer(http.HTTPServer):
@staticmethod
@web.middleware
- async def validate_auth(req: http.Request, handler):
+ async def validate_auth(req: web.Request, handler):
def get_token() -> str:
name = 'X-Token'
if name in req.headers:
@@ -70,13 +70,13 @@ class WebAPIServer(http.HTTPServer):
return await handler(req)
@staticmethod
- async def get_index(req: http.Request):
+ async def get_index(req: web.Request):
message = "nothing here, keep lurking"
if is_development_mode():
message += ' (dev mode)'
- return http.Response(text=message, content_type='text/plain')
+ return web.Response(text=message, content_type='text/plain')
- async def GET_sensors_data(self, req: http.Request):
+ async def GET_sensors_data(self, req: web.Request):
try:
hours = int(req.query['hours'])
if hours < 1 or hours > 24:
@@ -93,7 +93,7 @@ class WebAPIServer(http.HTTPServer):
data = db.get_temperature_recordings(sensor, (dt_from, dt_to))
return self.ok(data)
- async def GET_sound_sensors_hits(self, req: http.Request):
+ async def GET_sound_sensors_hits(self, req: web.Request):
location = SoundSensorLocation(int(req.query['location']))
after = int(req.query['after'])
@@ -112,7 +112,7 @@ class WebAPIServer(http.HTTPServer):
data = BotsDatabase().get_sound_hits(location, **kwargs)
return self.ok(data)
- async def POST_sound_sensors_hits(self, req: http.Request):
+ async def POST_sound_sensors_hits(self, req: web.Request):
hits = []
data = await req.post()
for hit, count in json.loads(data['hits']):
@@ -125,7 +125,7 @@ class WebAPIServer(http.HTTPServer):
BotsDatabase().add_sound_hits(hits, datetime.now())
return self.ok()
- async def POST_openwrt_log(self, req: http.Request):
+ async def POST_openwrt_log(self, req: web.Request):
data = await req.post()
try:
@@ -154,7 +154,7 @@ class WebAPIServer(http.HTTPServer):
BotsDatabase().add_openwrt_logs(lines, ap)
return self.ok()
- async def GET_recordings_list(self, req: http.Request):
+ async def GET_recordings_list(self, req: web.Request):
data = await req.post()
try:
@@ -176,7 +176,7 @@ class WebAPIServer(http.HTTPServer):
return self.ok(files)
@staticmethod
- def _get_inverter_from_to(req: http.Request):
+ def _get_inverter_from_to(req: web.Request):
s_from = req.query['from']
s_to = req.query['to']
@@ -189,12 +189,12 @@ class WebAPIServer(http.HTTPServer):
return dt_from, dt_to
- async def GET_consumed_energy(self, req: http.Request):
+ async def GET_consumed_energy(self, req: web.Request):
dt_from, dt_to = self._get_inverter_from_to(req)
wh = InverterDatabase().get_consumed_energy(dt_from, dt_to)
return self.ok(wh)
- async def GET_grid_consumed_energy(self, req: http.Request):
+ async def GET_grid_consumed_energy(self, req: web.Request):
dt_from, dt_to = self._get_inverter_from_to(req)
wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to)
return self.ok(wh)
diff --git a/bin/web_kbn.py b/bin/web_kbn.py
index bf17046..a0f12a6 100644
--- a/bin/web_kbn.py
+++ b/bin/web_kbn.py
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
+import __py_include
import asyncio
+import logging
import jinja2
import aiohttp_jinja2
import json
@@ -7,18 +9,18 @@ import re
import inverterd
import phonenumbers
import time
-import __py_include
from io import StringIO
-from aiohttp.web import HTTPFound, HTTPBadRequest
+from aiohttp import web
from typing import Optional, Union
+from urllib.parse import quote_plus
from homekit.config import config, AppConfigUnit, is_development_mode, Translation
from homekit.camera import IpcamConfig
-from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial
+from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial, validate_ipv4
from homekit.modem import E3372, ModemsConfig, MacroNetWorkType
from homekit.inverter.config import InverterdConfig
from homekit.relay.sunxi_h3_client import RelayClient
-from homekit import http
+from homekit import openwrt, http
class WebKbnConfig(AppConfigUnit):
@@ -36,13 +38,16 @@ class WebKbnConfig(AppConfigUnit):
}
-STATIC_FILES = [
+common_static_files = [
'bootstrap.min.css',
'bootstrap.min.js',
'polyfills.js',
'app.js',
'app.css'
]
+routes = web.RouteTableDef()
+webkbn_strings = Translation('web_kbn')
+logger = logging.getLogger(__name__)
def get_js_link(file, version) -> str:
@@ -65,7 +70,7 @@ def get_head_static(files=None) -> str:
buf = StringIO()
if files is None:
files = []
- for file in STATIC_FILES+files:
+ for file in common_static_files + files:
v = 2
try:
q_ind = file.index('?')
@@ -179,245 +184,356 @@ def get_inverter_data() -> tuple:
return status, rated, html
-class WebSite(http.HTTPServer):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+def get_current_upstream() -> str:
+ r = openwrt.get_default_route()
+ logger.info(f'default route: {r}')
+ mc = ModemsConfig()
+ for k, v in mc.items():
+ if 'gateway_ip' in v and v['gateway_ip'] == r:
+ r = v['ip']
+ break
+ upstream = None
+ for k, v in mc.items():
+ if r == v['ip']:
+ upstream = k
+ if not upstream:
+ raise RuntimeError('failed to determine current upstream!')
+ return upstream
+
+
+def lang(key: str):
+ return webkbn_strings.get()[key]
+
+
+async def render(req: web.Request,
+ template_name: str,
+ title: Optional[str] = None,
+ context: Optional[dict] = None,
+ assets: Optional[list] = None):
+ if context is None:
+ context = {}
+ context = {
+ **context,
+ 'head_static': get_head_static(assets)
+ }
+ if title is not None:
+ context['title'] = title
+ response = aiohttp_jinja2.render_template(template_name+'.j2', req, context=context)
+ return response
+
+
+@routes.get('/main.cgi')
+async def index(req: web.Request):
+ ctx = {}
+ for k in 'inverter', 'sensors':
+ ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url']
+
+ cc = IpcamConfig()
+ ctx['camzones'] = cc['zones'].keys()
+ ctx['allcams'] = cc.get_all_cam_names()
+
+ return await render(req, 'index',
+ title=lang('sitename'),
+ context=ctx)
+
+
+@routes.get('/modems.cgi')
+async def modems(req: web.Request):
+ return await render(req, 'modems',
+ title='Состояние модемов',
+ context=dict(modems=ModemsConfig()))
+
+
+@routes.get('/modems/info.ajx')
+async def modems_ajx(req: web.Request):
+ mc = ModemsConfig()
+ modem = req.query.get('id', None)
+ if modem not in mc.keys():
+ raise ValueError('invalid modem id')
+
+ modem_cfg = mc.get(modem)
+ loop = asyncio.get_event_loop()
+ modem_data = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg))
+
+ html = aiohttp_jinja2.render_string('modem_data.j2', req, context=dict(
+ modem_data=modem_data,
+ modem=modem
+ ))
+
+ return http.ajax_ok({'html': html})
+
+
+@routes.get('/modems/verbose.cgi')
+async def modems_verbose(req: web.Request):
+ modem = req.query.get('id', None)
+ if modem not in ModemsConfig().keys():
+ raise ValueError('invalid modem id')
+
+ modem_cfg = ModemsConfig().get(modem)
+ loop = asyncio.get_event_loop()
+ signal, status, traffic, device, dialup_conn = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg, True))
+ data = [
+ ['Signal', signal],
+ ['Connection', status],
+ ['Traffic', traffic],
+ ['Device info', device],
+ ['Dialup connection', dialup_conn]
+ ]
+
+ modem_name = ModemsConfig().getfullname(modem)
+ return await render(req, 'modem_verbose',
+ title=f'Подробная информация о модеме "{modem_name}"',
+ context=dict(data=data, modem_name=modem_name))
+
+
+@routes.get('/sms.cgi')
+async def sms(req: web.Request):
+ modem = req.query.get('id', list(ModemsConfig().keys())[0])
+ is_outbox = int(req.query.get('outbox', 0)) == 1
+ error = req.query.get('error', None)
+ sent = int(req.query.get('sent', 0)) == 1
+
+ cl = get_modem_client(ModemsConfig()[modem])
+ messages = cl.sms_list(1, 20, is_outbox)
+ return await render(req, 'sms',
+ title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})",
+ context=dict(
+ modems=ModemsConfig(),
+ selected_modem=modem,
+ is_outbox=is_outbox,
+ error=error,
+ is_sent=sent,
+ messages=messages
+ ))
+
+
+@routes.post('/sms.cgi')
+async def sms_post(req: web.Request):
+ modem = req.query.get('id', list(ModemsConfig().keys())[0])
+ is_outbox = int(req.query.get('outbox', 0)) == 1
+
+ fd = await req.post()
+ phone = fd.get('phone', None)
+ text = fd.get('text', None)
+
+ return_url = f'/sms.cgi?id={modem}&outbox={int(is_outbox)}'
+ phone = re.sub('\s+', '', phone)
+
+ if len(phone) > 4:
+ country = None
+ if not phone.startswith('+'):
+ country = 'RU'
+ number = phonenumbers.parse(phone, country)
+ if not phonenumbers.is_valid_number(number):
+ raise web.HTTPFound(f'{return_url}&error=Неверный+номер')
+ phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
+
+ cl = get_modem_client(ModemsConfig()[modem])
+ cl.sms_send(phone, text)
+ raise web.HTTPFound(return_url)
+
+
+@routes.get('/inverter.cgi')
+async def inverter(req: web.Request):
+ action = req.query.get('do', None)
+ if action == 'set-osp':
+ val = req.query.get('value')
+ if val not in ('sub', 'sbu'):
+ raise ValueError('invalid osp value')
+ cl = get_inverter_client()
+ cl.exec('set-output-source-priority',
+ arguments=(val.upper(),))
+ raise web.HTTPFound('/inverter.cgi')
+
+ status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
+ return await render(req, 'inverter',
+ title=lang('inverter'),
+ context=dict(status=status, rated=rated, html=html))
+
+
+@routes.get('/inverter.ajx')
+async def inverter_ajx(req: web.Request):
+ status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
+ return http.ajax_ok({'html': html})
+
+
+@routes.get('/pump.cgi')
+async def pump(req: web.Request):
+ # TODO
+ # these are blocking calls
+ # should be rewritten using aio
+
+ cl = get_pump_client()
+
+ action = req.query.get('set', None)
+ if action in ('on', 'off'):
+ getattr(cl, action)()
+ raise web.HTTPFound('/pump.cgi')
+
+ status = cl.status()
+ return await render(req, 'pump',
+ title=lang('pump'),
+ context=dict(status=status))
+
+
+@routes.get('/cams.cgi')
+async def cams(req: web.Request):
+ cc = IpcamConfig()
+
+ cam = req.query.get('id', None)
+ zone = req.query.get('zone', None)
+ debug_hls = bool(req.query.get('debug_hls', False))
+ debug_video_events = bool(req.query.get('debug_video_events', False))
+
+ if cam is not None:
+ if not cc.has_camera(int(cam)):
+ raise ValueError('invalid camera id')
+ cams = [int(cam)]
+ mode = {'type': 'single', 'cam': cam}
+
+ elif zone is not None:
+ if not cc.has_zone(zone):
+ raise ValueError('invalid zone')
+ cams = cc['zones'][zone]
+ mode = {'type': 'zone', 'zone': zone}
- aiohttp_jinja2.setup(
- self.app,
- loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
- autoescape=jinja2.select_autoescape(['html', 'xml']),
- )
- env = aiohttp_jinja2.get_env(self.app)
+ else:
+ raise web.HTTPBadRequest(text='no camera id or zone found')
+
+ js_config = {
+ 'host': config.app_config['cam_hls_host'],
+ 'proto': 'http',
+ 'cams': cams,
+ 'hlsConfig': {
+ 'opts': {
+ 'startPosition': -1,
+ # https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784
+ 'liveSyncDuration': 2,
+ 'liveMaxLatencyDuration': 3,
+ 'maxLiveSyncPlaybackRate': 2,
+ 'liveDurationInfinity': True
+ },
+ 'debugVideoEvents': debug_video_events,
+ 'debug': debug_hls
+ }
+ }
- def filter_lang(key, unit='web_kbn'):
- strings = Translation(unit)
- return strings.get()[key]
+ return await render(req, 'cams',
+ title=lang('cams'),
+ assets=['hls.js'],
+ context=dict(
+ mode=mode,
+ js_config=js_config,
+ ))
- env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
- env.filters['lang'] = filter_lang
- self.app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
+@routes.get('/routing/main.cgi')
+async def routing_main(req: web.Request):
+ upstream = get_current_upstream()
- self.get('/main.cgi', self.index)
+ set_upstream_to = req.query.get('set-upstream-to', None)
+ mc = ModemsConfig()
- self.get('/modems.cgi', self.modems)
- self.get('/modems/info.ajx', self.modems_ajx)
- self.get('/modems/verbose.cgi', self.modems_verbose)
+ if set_upstream_to and set_upstream_to in mc and set_upstream_to != upstream:
+ modem = mc[set_upstream_to]
+ new_upstream = str(modem['gateway_ip'] if 'gateway_ip' in modem else modem['ip'])
+ openwrt.set_upstream(new_upstream)
+ raise web.HTTPFound('/routing/main.cgi')
- self.get('/inverter.cgi', self.inverter)
- self.get('/inverter.ajx', self.inverter_ajx)
- self.get('/pump.cgi', self.pump)
- self.get('/sms.cgi', self.sms)
- self.post('/sms.cgi', self.sms_post)
+ context = dict(
+ upstream=upstream,
+ selected_tab='main',
+ modems=mc.keys()
+ )
+ return await render(req, 'routing_main', title=lang('routing'), context=context)
- self.get('/cams.cgi', self.cams)
- async def render_page(self,
- req: http.Request,
- template_name: str,
- title: Optional[str] = None,
- context: Optional[dict] = None,
- assets: Optional[list] = None):
- if context is None:
- context = {}
- context = {
- **context,
- 'head_static': get_head_static(assets)
- }
- if title is not None:
- context['title'] = title
- response = aiohttp_jinja2.render_template(template_name+'.j2', req, context=context)
- return response
-
- async def index(self, req: http.Request):
- ctx = {}
- for k in 'inverter', 'sensors':
- ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url']
-
- cc = IpcamConfig()
- ctx['camzones'] = cc['zones'].keys()
- ctx['allcams'] = cc.get_all_cam_names()
-
- return await self.render_page(req, 'index',
- title="Home web site",
- context=ctx)
-
- async def modems(self, req: http.Request):
- return await self.render_page(req, 'modems',
- title='Состояние модемов',
- context=dict(modems=ModemsConfig()))
-
- async def modems_ajx(self, req: http.Request):
- mc = ModemsConfig()
- modem = req.query.get('id', None)
- if modem not in mc.keys():
- raise ValueError('invalid modem id')
-
- modem_cfg = mc.get(modem)
- loop = asyncio.get_event_loop()
- modem_data = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg))
-
- html = aiohttp_jinja2.render_string('modem_data.j2', req, context=dict(
- modem_data=modem_data,
- modem=modem
- ))
-
- return self.ok({'html': html})
-
- async def modems_verbose(self, req: http.Request):
- modem = req.query.get('id', None)
- if modem not in ModemsConfig().keys():
- raise ValueError('invalid modem id')
-
- modem_cfg = ModemsConfig().get(modem)
- loop = asyncio.get_event_loop()
- signal, status, traffic, device, dialup_conn = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg, True))
- data = [
- ['Signal', signal],
- ['Connection', status],
- ['Traffic', traffic],
- ['Device info', device],
- ['Dialup connection', dialup_conn]
- ]
-
- modem_name = ModemsConfig().getfullname(modem)
- return await self.render_page(req, 'modem_verbose',
- title=f'Подробная информация о модеме "{modem_name}"',
- context=dict(data=data, modem_name=modem_name))
-
- async def sms(self, req: http.Request):
- modem = req.query.get('id', list(ModemsConfig().keys())[0])
- is_outbox = int(req.query.get('outbox', 0)) == 1
- error = req.query.get('error', None)
- sent = int(req.query.get('sent', 0)) == 1
-
- cl = get_modem_client(ModemsConfig()[modem])
- messages = cl.sms_list(1, 20, is_outbox)
- return await self.render_page(req, 'sms',
- title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})",
- context=dict(
- modems=ModemsConfig(),
- selected_modem=modem,
- is_outbox=is_outbox,
- error=error,
- is_sent=sent,
- messages=messages
- ))
-
- async def sms_post(self, req: http.Request):
- modem = req.query.get('id', list(ModemsConfig().keys())[0])
- is_outbox = int(req.query.get('outbox', 0)) == 1
-
- fd = await req.post()
- phone = fd.get('phone', None)
- text = fd.get('text', None)
-
- return_url = f'/sms.cgi?id={modem}&outbox={int(is_outbox)}'
- phone = re.sub('\s+', '', phone)
-
- if len(phone) > 4:
- country = None
- if not phone.startswith('+'):
- country = 'RU'
- number = phonenumbers.parse(phone, country)
- if not phonenumbers.is_valid_number(number):
- raise HTTPFound(f'{return_url}&error=Неверный+номер')
- phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
-
- cl = get_modem_client(ModemsConfig()[modem])
- cl.sms_send(phone, text)
- raise HTTPFound(return_url)
-
- async def inverter(self, req: http.Request):
- action = req.query.get('do', None)
- if action == 'set-osp':
- val = req.query.get('value')
- if val not in ('sub', 'sbu'):
- raise ValueError('invalid osp value')
- cl = get_inverter_client()
- cl.exec('set-output-source-priority',
- arguments=(val.upper(),))
- raise HTTPFound('/inverter.cgi')
-
- status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
- return await self.render_page(req, 'inverter',
- title='Инвертор',
- context=dict(status=status, rated=rated, html=html))
-
- async def inverter_ajx(self, req: http.Request):
- status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
- return self.ok({'html': html})
-
- async def pump(self, req: http.Request):
- # TODO
- # these are blocking calls
- # should be rewritten using aio
-
- cl = get_pump_client()
-
- action = req.query.get('set', None)
- if action in ('on', 'off'):
- getattr(cl, action)()
- raise HTTPFound('/pump.cgi')
-
- status = cl.status()
- return await self.render_page(req, 'pump',
- title='Насос',
- context=dict(status=status))
-
- async def cams(self, req: http.Request):
- cc = IpcamConfig()
-
- cam = req.query.get('id', None)
- zone = req.query.get('zone', None)
- debug_hls = bool(req.query.get('debug_hls', False))
- debug_video_events = bool(req.query.get('debug_video_events', False))
-
- if cam is not None:
- if not cc.has_camera(int(cam)):
- raise ValueError('invalid camera id')
- cams = [int(cam)]
- mode = {'type': 'single', 'cam': cam}
-
- elif zone is not None:
- if not cc.has_zone(zone):
- raise ValueError('invalid zone')
- cams = cc['zones'][zone]
- mode = {'type': 'zone', 'zone': zone}
+@routes.get('/routing/rules.cgi')
+async def routing_rules(req: web.Request):
+ mc = ModemsConfig()
+
+ action = req.query.get('action', None)
+ error = req.query.get('error', None)
+ set_name = req.query.get('set', None)
+ ip = req.query.get('ip', None)
+
+ def validate_input():
+ # validate set
+ if not set_name or set_name not in mc:
+ raise ValueError(f'invalid set \'{set_name}\'')
+
+ # validate ip
+ if not isinstance(ip, str):
+ raise ValueError('invalid ip')
+
+ slash_pos = None
+ try:
+ slash_pos = ip.index('/')
+ except ValueError:
+ pass
+ if slash_pos is not None:
+ ip_without_mask = ip[0:slash_pos]
else:
- raise HTTPBadRequest(text='no camera id or zone found')
-
- js_config = {
- 'host': config.app_config['cam_hls_host'],
- 'proto': 'http',
- 'cams': cams,
- 'hlsConfig': {
- 'opts': {
- 'startPosition': -1,
- # https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784
- 'liveSyncDuration': 2,
- 'liveMaxLatencyDuration': 3,
- 'maxLiveSyncPlaybackRate': 2,
- 'liveDurationInfinity': True
- },
- 'debugVideoEvents': debug_video_events,
- 'debug': debug_hls
- }
- }
+ ip_without_mask = ip
+ if not validate_ipv4(ip_without_mask):
+ raise ValueError(f'invalid ip \'{ip}\'')
+
+ base_url = '/routing/rules.cgi'
+ if action in ('add', 'del'):
+ try:
+ validate_input()
+ except ValueError as e:
+ raise web.HTTPFound(f'{base_url}?error='+quote_plus(str(e)))
+ f = getattr(openwrt, f'ipset_{action}')
+ output = f(set_name, ip)
+ url = base_url
+ if output != '':
+ url += '?error='+quote_plus(output)
+ raise web.HTTPFound(url)
+
+ ipsets = openwrt.ipset_list_all()
+ context = dict(
+ sets=ipsets,
+ selected_tab='rules',
+ error=error
+ )
+ return await render(req, 'routing_rules',
+ title=lang('routing') + ' // ' + lang('routing_rules'),
+ context=context)
+
+
+@routes.get('/routing/dhcp.cgi')
+async def routing_dhcp(req: web.Request):
+ leases = openwrt.get_dhcp_leases()
+ return await render(req, 'routing_dhcp',
+ title=lang('routing') + ' // DHCP',
+ context=dict(leases=leases, selected_tab='dhcp'))
+
+
+def init_web_app(app: web.Application):
+ aiohttp_jinja2.setup(
+ app,
+ loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
+ autoescape=jinja2.select_autoescape(['html', 'xml']),
+ )
+ env = aiohttp_jinja2.get_env(app)
+
+ def filter_lang(key, unit='web_kbn'):
+ strings = Translation(unit)
+ if isinstance(key, str) and '.' in key:
+ return strings.get().get(key)
+ else:
+ return strings.get()[key]
- return await self.render_page(req, 'cams',
- title='Камеры',
- assets=['hls.js'],
- context=dict(
- mode=mode,
- js_config=js_config,
- ))
+ env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
+ env.filters['lang'] = filter_lang
+
+ app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
if __name__ == '__main__':
config.load_app(WebKbnConfig)
-
- server = WebSite(config.app_config['listen_addr'])
- server.run()
+ http.serve(addr=config.app_config['listen_addr'],
+ routes=routes,
+ before_start=init_web_app)
diff --git a/doc/localwebsite.md b/doc/localwebsite.md
deleted file mode 100644
index b46f62e..0000000
--- a/doc/localwebsite.md
+++ /dev/null
@@ -1,7 +0,0 @@
-## Dependencies
-
-```
-apt install nginx-extras php-fpm php-mbstring php-sqlite3 php-curl php-simplexml php-gmp composer
-```
-
-
diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py
index 3aa0e04..1eec97d 100644
--- a/include/py/homekit/config/config.py
+++ b/include/py/homekit/config/config.py
@@ -67,7 +67,7 @@ class BaseConfigUnit(ABC):
return self._data
cur = self._data
- pts = key.split('.')
+ pts = str(key).split('.')
for i in range(len(pts)):
k = pts[i]
if i < len(pts)-1:
diff --git a/include/py/homekit/http/__init__.py b/include/py/homekit/http/__init__.py
index d019e4c..f3721a4 100644
--- a/include/py/homekit/http/__init__.py
+++ b/include/py/homekit/http/__init__.py
@@ -1,2 +1 @@
-from .http import serve, ok, routes, HTTPServer, HTTPMethod
-from aiohttp.web import FileResponse, StreamResponse, Request, Response \ No newline at end of file
+from .http import serve, ajax_ok, HTTPMethod
diff --git a/include/py/homekit/http/http.py b/include/py/homekit/http/http.py
index 8819c46..a8c7d82 100644
--- a/include/py/homekit/http/http.py
+++ b/include/py/homekit/http/http.py
@@ -1,17 +1,46 @@
import logging
import asyncio
+import html
from enum import Enum
from aiohttp import web
-from aiohttp.web import Response, HTTPFound
+from aiohttp.web import HTTPFound
from aiohttp.web_exceptions import HTTPNotFound
-
from ..util import stringify, format_tb, Addr
-
_logger = logging.getLogger(__name__)
+def _render_error(error_type, error_message, traceback=None, code=500):
+ traceback_html = ''
+ if traceback:
+ traceback = '\n\n'.join(traceback)
+ traceback_html = f"""
+<div class="error_traceback">
+ <div class="error_title">Traceback</div>
+ <div class="error_traceback_content">{html.escape(traceback)}</div>
+</div>
+"""
+
+ buf = f"""
+<!doctype html>
+<html lang=en>
+<head>
+<title>Error: {html.escape(error_type)}</title>
+<meta http-equiv="content-type" content="text/html; charset=utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<link rel="stylesheet" type="text/css" href="/assets/error_page.css">
+</head>
+<body>
+<div class="error_title">{html.escape(error_type)}</div>
+<div class="error_message">{html.escape(error_message)}</div>
+{traceback_html}
+</body>
+</html>
+"""
+ return web.Response(text=buf, status=code, content_type='text/html')
+
+
@web.middleware
async def errors_handler_middleware(request, handler):
try:
@@ -19,97 +48,56 @@ async def errors_handler_middleware(request, handler):
return response
except HTTPNotFound:
- return web.json_response({'error': 'not found'}, status=404)
+ return _render_error(
+ error_type='Not Found',
+ error_message='The page you requested has not been found.',
+ code=404
+ )
except HTTPFound as exc:
raise exc
except Exception as exc:
_logger.exception(exc)
- data = {
- 'error': exc.__class__.__name__,
- 'message': exc.message if hasattr(exc, 'message') else str(exc)
- }
- tb = format_tb(exc)
- if tb:
- data['stacktrace'] = tb
+ return _render_error(
+ error_type=exc.__class__.__name__,
+ error_message=exc.message if hasattr(exc, 'message') else str(exc),
+ traceback=format_tb(exc)
+ )
- return web.json_response(data, status=500)
-
-def serve(addr: Addr, route_table: web.RouteTableDef, handle_signals: bool = True):
+def serve(addr: Addr, before_start=None, handle_signals=True, routes=None, event_loop=None):
app = web.Application()
- app.add_routes(route_table)
app.middlewares.append(errors_handler_middleware)
- host, port = addr
+ if routes is not None:
+ app.add_routes(routes)
- web.run_app(app,
- host=host,
- port=port,
- handle_signals=handle_signals)
+ if callable(before_start):
+ before_start(app)
+ if not event_loop:
+ event_loop = asyncio.get_event_loop()
-def routes() -> web.RouteTableDef:
- return web.RouteTableDef()
+ runner = web.AppRunner(app, handle_signals=handle_signals)
+ event_loop.run_until_complete(runner.setup())
+ host, port = addr
+ site = web.TCPSite(runner, host=host, port=port)
+ event_loop.run_until_complete(site.start())
-def ok(data=None):
+ _logger.info(f'Server started at http://{host}:{port}')
+
+ event_loop.run_forever()
+
+
+def ajax_ok(data=None):
if data is None:
data = 1
response = {'response': data}
return web.json_response(response, dumps=stringify)
-class HTTPServer:
- def __init__(self, addr: Addr, handle_errors=True):
- self.addr = addr
- self.app = web.Application()
- self.logger = logging.getLogger(self.__class__.__name__)
-
- if handle_errors:
- self.app.middlewares.append(errors_handler_middleware)
-
- def _add_route(self,
- method: str,
- path: str,
- handler: callable):
- self.app.router.add_routes([getattr(web, method)(path, handler)])
-
- def get(self, path, handler):
- self._add_route('get', path, handler)
-
- def post(self, path, handler):
- self._add_route('post', path, handler)
-
- def put(self, path, handler):
- self._add_route('put', path, handler)
-
- def delete(self, path, handler):
- self._add_route('delete', path, handler)
-
- def run(self, event_loop=None, handle_signals=True):
- if not event_loop:
- event_loop = asyncio.get_event_loop()
-
- runner = web.AppRunner(self.app, handle_signals=handle_signals)
- event_loop.run_until_complete(runner.setup())
-
- host, port = self.addr
- site = web.TCPSite(runner, host=host, port=port)
- event_loop.run_until_complete(site.start())
-
- self.logger.info(f'Server started at http://{host}:{port}')
-
- event_loop.run_forever()
-
- def ok(self, data=None):
- return ok(data)
-
- def plain(self, text: str):
- return Response(text=text, content_type='text/plain')
-
-
class HTTPMethod(Enum):
GET = 'GET'
POST = 'POST'
diff --git a/include/py/homekit/media/node_server.py b/include/py/homekit/media/node_server.py
index 5d0803c..229b9f7 100644
--- a/include/py/homekit/media/node_server.py
+++ b/include/py/homekit/media/node_server.py
@@ -33,12 +33,12 @@ class MediaNodeServer(http.HTTPServer):
raise ValueError(f'invalid duration: max duration is {max}')
record_id = self.recorder.record(duration)
- return http.ok({'id': record_id})
+ return http.ajax_ok({'id': record_id})
async def record_info(self, request: http.Request):
record_id = int(request.match_info['id'])
info = self.recorder.get_info(record_id)
- return http.ok(info.as_dict())
+ return http.ajax_ok(info.as_dict())
async def record_forget(self, request: http.Request):
record_id = int(request.match_info['id'])
@@ -47,7 +47,7 @@ class MediaNodeServer(http.HTTPServer):
assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
self.recorder.forget(record_id)
- return http.ok()
+ return http.ajax_ok()
async def record_download(self, request: http.Request):
record_id = int(request.match_info['id'])
@@ -64,7 +64,7 @@ class MediaNodeServer(http.HTTPServer):
if extended:
files = list(map(lambda file: file.__dict__(), files))
- return http.ok({
+ return http.ajax_ok({
'files': files
})
@@ -75,7 +75,7 @@ class MediaNodeServer(http.HTTPServer):
raise ValueError(f'file {file} not found')
self.storage.delete(file)
- return http.ok()
+ return http.ajax_ok()
async def storage_download(self, request):
file_id = request.query['file_id']
diff --git a/include/py/homekit/openwrt/__init__.py b/include/py/homekit/openwrt/__init__.py
new file mode 100644
index 0000000..b233b00
--- /dev/null
+++ b/include/py/homekit/openwrt/__init__.py
@@ -0,0 +1,9 @@
+from .config import OpenwrtConfig
+from .openwrt import (
+ ipset_list_all,
+ ipset_add,
+ ipset_del,
+ set_upstream,
+ get_default_route,
+ get_dhcp_leases
+)
diff --git a/include/py/homekit/openwrt/config.py b/include/py/homekit/openwrt/config.py
new file mode 100644
index 0000000..bd75d1c
--- /dev/null
+++ b/include/py/homekit/openwrt/config.py
@@ -0,0 +1,14 @@
+from typing import Optional
+
+from homekit.config import ConfigUnit
+
+
+class OpenwrtConfig(ConfigUnit):
+ NAME = 'openwrt'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'ip': cls._addr_schema(only_ip=True, required=True),
+ 'command_id': {'type': 'string', 'required': True}
+ } \ No newline at end of file
diff --git a/include/py/homekit/openwrt/openwrt.py b/include/py/homekit/openwrt/openwrt.py
new file mode 100644
index 0000000..d5f949c
--- /dev/null
+++ b/include/py/homekit/openwrt/openwrt.py
@@ -0,0 +1,90 @@
+import requests
+import logging
+
+from datetime import datetime
+from collections import namedtuple
+from urllib.parse import quote_plus
+from .config import OpenwrtConfig
+from ..modem.config import ModemsConfig
+
+DHCPLease = namedtuple('DHCPLease', 'time, time_s, mac, ip, hostname')
+_config = OpenwrtConfig()
+_modems_config = ModemsConfig()
+_logger = logging.getLogger(__name__)
+
+
+def ipset_list_all() -> list:
+ args = ['ipset-list-all']
+ args += _modems_config.keys()
+ lines = _to_list(_call(args))
+ sets = {}
+ cur_set = None
+ for line in lines:
+ if line.startswith('>'):
+ cur_set = line[1:]
+ if cur_set not in sets:
+ sets[cur_set] = []
+ continue
+
+ if cur_set is None:
+ _logger.error('ipset_list_all: cur_set is not set')
+ continue
+
+ sets[cur_set].append(line)
+
+ return sets
+
+
+def ipset_add(set_name: str, ip: str):
+ return _call(['ipset-add', set_name, ip])
+
+
+def ipset_del(set_name: str, ip: str):
+ return _call(['ipset-del', set_name, ip])
+
+
+def set_upstream(ip: str):
+ return _call(['homekit-set-default-upstream', ip])
+
+
+def get_default_route():
+ return _call(['get-default-route'])
+
+
+def get_dhcp_leases() -> list[DHCPLease]:
+ return list(map(lambda item: _to_dhcp_lease(item), _to_list(_call(['dhcp-leases']))))
+
+
+def _call(arguments: list[str]) -> str:
+ url = _get_link(arguments)
+ r = requests.get(url)
+ r.raise_for_status()
+ return r.text.strip()
+
+
+def _get_link(arguments: list[str]) -> str:
+ url = f'http://{_config["ip"]}/cgi-bin/luci/command/{_config["command_id"]}'
+ if arguments:
+ url += '/'
+ url += '%20'.join(list(map(lambda arg: quote_plus(arg.replace('/', '_')), arguments)))
+ return url
+
+
+def _to_list(s: str) -> list:
+ return [] if s == '' else s.split('\n')
+
+
+def _to_dhcp_lease(s: str) -> DHCPLease:
+ words = s.split(' ')
+ time = int(words.pop(0))
+ mac = words.pop(0)
+ ip = words.pop(0)
+ words.pop()
+ hostname = (' '.join(words)).strip()
+ if not hostname or hostname == '*':
+ hostname = '?'
+ return DHCPLease(time=time,
+ time_s=datetime.fromtimestamp(time).strftime('%d %b, %H:%M:%S'),
+ mac=mac,
+ ip=ip,
+ hostname=hostname) \ No newline at end of file
diff --git a/include/py/homekit/soundsensor/server.py b/include/py/homekit/soundsensor/server.py
index a627390..d6320c1 100644
--- a/include/py/homekit/soundsensor/server.py
+++ b/include/py/homekit/soundsensor/server.py
@@ -3,6 +3,7 @@ import json
import logging
import threading
+from aiohttp import web
from ..database.sqlite import SQLiteBase
from ..config import config
from .. import http
@@ -108,21 +109,21 @@ class SoundSensorServer:
loop.run_forever()
def run_guard_server(self):
- routes = http.routes()
+ routes = web.RouteTableDef()
@routes.post('/guard/enable')
async def guard_enable(request):
self.set_recording(True)
- return http.ok()
+ return http.ajax_ok()
@routes.post('/guard/disable')
async def guard_disable(request):
self.set_recording(False)
- return http.ok()
+ return http.ajax_ok()
@routes.get('/guard/status')
async def guard_status(request):
- return http.ok({'enabled': self.is_recording_enabled()})
+ return http.ajax_ok({'enabled': self.is_recording_enabled()})
asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread
- http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread
+ http.serve(self.addr, handle_signals=False) # handle_signals=True doesn't work in separate thread
diff --git a/include/py/homekit/util.py b/include/py/homekit/util.py
index 4410251..2d76968 100644
--- a/include/py/homekit/util.py
+++ b/include/py/homekit/util.py
@@ -105,6 +105,11 @@ class Addr:
yield self.host
yield self.port
+ def __eq__(self, other):
+ if isinstance(other, str):
+ return self.__str__() == other
+ return NotImplemented
+
# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks
def chunks(lst, n):
diff --git a/localwebsite/classes/MyOpenWrtUtils.php b/localwebsite/classes/MyOpenWrtUtils.php
deleted file mode 100644
index c140fa1..0000000
--- a/localwebsite/classes/MyOpenWrtUtils.php
+++ /dev/null
@@ -1,139 +0,0 @@
-<?php
-
-class MyOpenWrtUtils {
-
- // public static function getRoutingTable(?string $table = null): array {
- // $arguments = ['route-show'];
- // if ($table)
- // $arguments[] = $table;
- //
- // return self::toList(self::run($arguments));
- // }
- //
- // public static function getRoutingRules(): array {
- // return self::toList(self::run(['rule-show']));
- // }
- //
- // public static function ipsetList(string $set_name): array {
- // return self::toList(self::run(['ipset-list', $set_name]));
- // }
-
- public static function ipsetListAll(): array {
- global $config;
-
- $args = ['ipset-list-all'];
- $args = array_merge($args, array_keys($config['modems']));
-
- $lines = self::toList(self::run($args));
-
- $sets = [];
- $cur_set = null;
- foreach ($lines as $line) {
- if (startsWith($line, '>')) {
- $cur_set = substr($line, 1);
- if (!isset($sets[$cur_set]))
- $sets[$cur_set] = [];
- continue;
- }
-
- if (is_null($cur_set)) {
- debugError(__METHOD__.': cur_set is not set');
- continue;
- }
-
- $sets[$cur_set][] = $line;
- }
-
- return $sets;
- }
-
- public static function ipsetAdd(string $set_name, string $ip) {
- return self::run(['ipset-add', $set_name, $ip]);
- }
-
- public static function ipsetDel(string $set_name, string $ip) {
- return self::run(['ipset-del', $set_name, $ip]);
- }
-
- public static function getDHCPLeases(): array {
- $list = self::toList(self::run(['dhcp-leases']));
- $list = array_map('self::toDHCPLease', $list);
- return $list;
- }
-
- public static function setUpstream(string $ip) {
- return self::run(['homekit-set-default-upstream', $ip]);
- }
-
- public static function getDefaultRoute() {
- return self::run(['get-default-route']);
- }
-
-
- //
- // http functions
- //
-
- private static function run(array $arguments) {
- $url = self::getLink($arguments);
-
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- $body = curl_exec($ch);
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- if ($code != 200)
- throw new Exception(__METHOD__.': http code '.$code);
-
- curl_close($ch);
- return trim($body);
- }
-
- private static function getLink($arguments) {
- global $config;
-
- $url = 'http://'.$config['openwrt_ip'].'/cgi-bin/luci/command/cfg099944';
- if (!empty($arguments)) {
- $arguments = array_map(function($arg) {
- $arg = str_replace('/', '_', $arg);
- return urlencode($arg);
- }, $arguments);
- $arguments = implode('%20', $arguments);
-
- $url .= '/';
- $url .= $arguments;
- }
-
- return $url;
- }
-
-
- //
- // parsing functions
- //
-
- private static function toList(string $s): array {
- if ($s == '')
- return [];
- return explode("\n", $s);
- }
-
- private static function toDHCPLease(string $s): array {
- $words = explode(' ', $s);
- $time = array_shift($words);
- $mac = array_shift($words);
- $ip = array_shift($words);
- array_pop($words);
- $hostname = trim(implode(' ', $words));
- if (!$hostname || $hostname == '*')
- $hostname = '?';
- return [
- 'time' => $time,
- 'time_s' => date('d M, H:i:s', $time),
- 'mac' => $mac,
- 'ip' => $ip,
- 'hostname' => $hostname
- ];
- }
-
-}
diff --git a/localwebsite/classes/MySimpleSocketClient.php b/localwebsite/classes/MySimpleSocketClient.php
deleted file mode 100644
index e59efba..0000000
--- a/localwebsite/classes/MySimpleSocketClient.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-
-class MySimpleSocketClient {
-
- protected $sock;
-
- public function __construct(string $host, int $port)
- {
- if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) === false)
- throw new Exception("socket_create() failed: ".$this->getSocketError());
-
- $this->sock = $socket;
-
- if ((socket_connect($socket, $host, $port)) === false)
- throw new Exception("socket_connect() failed: ".$this->getSocketError());
- }
-
- public function __destruct()
- {
- $this->close();
- }
-
- /**
- * @throws Exception
- */
- public function send(string $data)
- {
- $data .= "\r\n";
- $remained = strlen($data);
-
- while ($remained > 0) {
- $result = socket_write($this->sock, $data);
- if ($result === false)
- throw new Exception(__METHOD__ . ": socket_write() failed: ".$this->getSocketError());
-
- $remained -= $result;
- if ($remained > 0)
- $data = substr($data, $result);
- }
- }
-
- /**
- * @throws Exception
- */
- public function recv()
- {
- $recv_buf = '';
- $buf = '';
-
- while (true) {
- $result = socket_recv($this->sock, $recv_buf, 1024, 0);
- if ($result === false)
- throw new Exception(__METHOD__ . ": socket_recv() failed: " . $this->getSocketError());
-
- // peer disconnected
- if ($result === 0)
- break;
-
- $buf .= $recv_buf;
- if (endsWith($buf, "\r\n"))
- break;
- }
-
- return trim($buf);
- }
-
- /**
- * Close connection.
- */
- public function close()
- {
- if (!$this->sock)
- return;
-
- socket_close($this->sock);
- $this->sock = null;
- }
-
- /**
- * @return string
- */
- protected function getSocketError(): string
- {
- $sle_args = [];
- if ($this->sock !== null)
- $sle_args[] = $this->sock;
- return socket_strerror(socket_last_error(...$sle_args));
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/classes/TemphumdClient.php b/localwebsite/classes/TemphumdClient.php
deleted file mode 100644
index 07e5a3e..0000000
--- a/localwebsite/classes/TemphumdClient.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-class TemphumdClient extends MySimpleSocketClient {
-
- public string $name;
- public float $temp;
- public float $humidity;
- public ?int $flags;
-
- /**
- * @throws Exception
- */
- public function __construct(string $host, int $port, string $name, ?int $flags = null) {
- parent::__construct($host, $port);
- $this->name = $name;
- $this->flags = $flags;
-
- socket_set_timeout($this->sock, 3);
- }
-
- public function readSensor(): void {
- $this->send('read');
-
- $data = jsonDecode($this->recv());
-
- $temp = round((float)$data['temp'], 3);
- $hum = round((float)$data['humidity'], 3);
-
- $this->temp = $temp;
- $this->humidity = $hum;
- }
-
- public function hasTemperature(): bool {
- return ($this->flags & config::TEMPHUMD_NO_TEMP) == 0;
- }
-
- public function hasHumidity(): bool {
- return ($this->flags & config::TEMPHUMD_NO_HUM) == 0;
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/classes/User.php b/localwebsite/classes/User.php
deleted file mode 100644
index 9019082..0000000
--- a/localwebsite/classes/User.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php
-
-class User extends model {
-
- const DB_TABLE = 'users';
-
- public int $id;
- public string $username;
- public string $password;
-
-}
diff --git a/localwebsite/classes/auth.php b/localwebsite/classes/auth.php
deleted file mode 100644
index a13843b..0000000
--- a/localwebsite/classes/auth.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-class auth {
-
- public static ?User $authorizedUser = null;
-
- const COOKIE_NAME = 'lws-auth';
-
- public static function getToken(): ?string {
- return $_COOKIE[self::COOKIE_NAME] ?? null;
- }
-
- public static function setToken(string $token) {
- setcookie_safe(self::COOKIE_NAME, $token);
- }
-
- public static function resetToken() {
- if (!headers_sent())
- unsetcookie(self::COOKIE_NAME);
- }
-
- public static function id(bool $do_check = true): int {
- if ($do_check)
- self::check();
-
- if (!self::$authorizedUser)
- return 0;
-
- return self::$authorizedUser->id;
- }
-
- public static function check(?string $pwhash = null): bool {
- if (self::$authorizedUser !== null)
- return true;
-
- // get auth token
- if (!$pwhash)
- $pwhash = self::getToken();
-
- if (!is_string($pwhash))
- return false;
-
- // find session by given token
- $user = users::getUserByPwhash($pwhash);
- if (is_null($user)) {
- self::resetToken();
- return false;
- }
-
- self::$authorizedUser = $user;
-
- return true;
- }
-
- public static function logout() {
- self::resetToken();
- self::$authorizedUser = null;
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/classes/config.php b/localwebsite/classes/config.php
deleted file mode 100644
index 927321e..0000000
--- a/localwebsite/classes/config.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?php
-
-class config {
-
- const TEMPHUMD_NO_TEMP = 1 << 0;
- const TEMPHUMD_NO_HUM = 1 << 1;
-
- public static function get(string $key) {
- global $config;
- return is_callable($config[$key]) ? $config[$key]() : $config[$key];
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/classes/users.php b/localwebsite/classes/users.php
deleted file mode 100644
index 1160dba..0000000
--- a/localwebsite/classes/users.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-class users {
-
- public static function add(string $username, string $password): int {
- $db = getDB();
- $db->insert('users', [
- 'username' => $username,
- 'password' => pwhash($password)
- ]);
- return $db->insertId();
- }
-
- public static function exists(string $username): bool {
- $db = getDB();
- $count = (int)$db->querySingle("SELECT COUNT(*) FROM users WHERE username=?", $username);
- return $count > 0;
- }
-
- public static function validatePassword(string $username, string $password): bool {
- $db = getDB();
- $row = $db->querySingleRow("SELECT * FROM users WHERE username=?", $username);
- if (!$row)
- return false;
-
- return $row['password'] == pwhash($password);
- }
-
- public static function getUserByPwhash(string $pwhash): ?User {
- $db = getDB();
- $data = $db->querySingleRow("SELECT * FROM users WHERE password=?", $pwhash);
- return $data ? new User($data) : null;
- }
-
- public static function setPassword(int $id, string $new_password) {
- getDB()->exec("UPDATE users SET password=? WHERE id=?", pwhash($new_password), $id);
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/engine/debug.php b/localwebsite/engine/debug.php
deleted file mode 100644
index b1b959f..0000000
--- a/localwebsite/engine/debug.php
+++ /dev/null
@@ -1,355 +0,0 @@
-<?php
-
-// require_once 'engine/mysql.php';
-
-class debug {
-
- protected static $Types = [
- 1 => 'E_ERROR',
- 2 => 'E_WARNING',
- 4 => 'E_PARSE',
- 8 => 'E_NOTICE',
- 16 => 'E_CORE_ERROR',
- 32 => 'E_CORE_WARNING',
- 64 => 'E_COMPILE_ERROR',
- 128 => 'E_COMPILE_WARNING',
- 256 => 'E_USER_ERROR',
- 512 => 'E_USER_WARNING',
- 1024 => 'E_USER_NOTICE',
- 2048 => 'E_STRICT',
- 4096 => 'E_RECOVERABLE_ERROR',
- 8192 => 'E_DEPRECATED',
- 16384 => 'E_USER_DEPRECATED',
- 32767 => 'E_ALL'
- ];
-
- const STORE_NONE = -1;
- const STORE_MYSQL = 0;
- const STORE_FILE = 1;
- const STORE_BOTH = 2;
-
- private static $instance = null;
-
- protected $enabled = false;
- protected $errCounter = 0;
- protected $logCounter = 0;
- protected $messagesStoreType = self::STORE_NONE;
- protected $errorsStoreType = self::STORE_NONE;
- protected $filter;
- protected $reportRecursionLevel = 0;
- protected $overridenDebugFile = null;
- protected $silent = false;
- protected $prefix;
-
- private function __construct($filter) {
- $this->filter = $filter;
- }
-
- public static function getInstance($filter = null) {
- if (is_null(self::$instance)) {
- self::$instance = new self($filter);
- }
- return self::$instance;
- }
-
- public function enable() {
- $self = $this;
-
- set_error_handler(function($no, $str, $file, $line) use ($self) {
- if ($self->silent || !$self->enabled) {
- return;
- }
- if ((is_callable($this->filter) && !($this->filter)($no, $file, $line, $str)) || !$self->canReport()) {
- return;
- }
- $self->report(true, $str, $no, $file, $line);
- });
-
- append_shutdown_function(function() use ($self) {
- if (!$self->enabled || !($error = error_get_last())) {
- return;
- }
- if (is_callable($this->filter)
- && !($this->filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
- return;
- }
- if (!$self->canReport()) {
- return;
- }
- $self->report(true, $error['message'], $error['type'], $error['file'], $error['line']);
- });
-
- $this->enabled = true;
- }
-
- public function disable() {
- restore_error_handler();
- $this->enabled = false;
- }
-
- public function report($is_error, $text, $errno = 0, $errfile = '', $errline = '') {
- global $config;
-
- $this->reportRecursionLevel++;
-
- $logstarted = $this->errCounter > 0 || $this->logCounter > 0;
- $num = $is_error ? $this->errCounter++ : $this->logCounter++;
- $custom = $is_error && !$errno;
- $ts = time();
- $exectime = exectime();
- $bt = backtrace(2);
-
- $store_file = (!$is_error && $this->checkMessagesStoreType(self::STORE_FILE))
- || ($is_error && $this->checkErrorsStoreType(self::STORE_FILE));
-
- $store_mysql = (!$is_error && $this->checkMessagesStoreType(self::STORE_MYSQL))
- || ($is_error && $this->checkErrorsStoreType(self::STORE_MYSQL));
-
- if ($this->prefix)
- $text = $this->prefix.$text;
-
- // if ($store_mysql) {
- // $db = getMySQL('local_logs', true);
- // $data = [
- // 'ts' => $ts,
- // 'num' => $num,
- // 'time' => $exectime,
- // 'custom' => intval($custom),
- // 'errno' => $errno,
- // 'file' => $errfile,
- // 'line' => $errline,
- // 'text' => $text,
- // 'stacktrace' => $bt,
- // 'is_cli' => PHP_SAPI == 'cli' ? 1 : 0,
- // ];
- // if (PHP_SAPI == 'cli') {
- // $data += [
- // 'ip' => '',
- // 'ua' => '',
- // 'url' => '',
- // ];
- // } else {
- // $data += [
- // 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
- // 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
- // 'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']
- // ];
- // }
- // $db->insert('backend_errors', $data);
- // }
-
- if ($store_file) {
- $title = PHP_SAPI == 'cli' ? 'cli' : $_SERVER['REQUEST_URI'];
- $date = date('d/m/y H:i:s', $ts);
- $exectime = (string)$exectime;
- if (strlen($exectime) < 6)
- $exectime .= str_repeat('0', 6 - strlen($exectime));
-
- $buf = "";
- if (!$logstarted) {
- $buf .= "\n<e fg=white bg=magenta style=fgbright,bold> {$title} </e><e fg=white bg=blue style=fgbright> {$date} </e>\n";
- }
- $buf .= "<e fg=".($is_error ? 'red' : 'white').">".($is_error ? 'E' : 'I')."=<e style=bold>${num}</e> <e fg=cyan>{$exectime}</e> ";
- if ($is_error && !$custom) {
- $buf .= "<e fg=green>{$errfile}<e fg=white>:<e fg=green style=fgbright>{$errline}</e> (".self::errname($errno).") ";
- }
- $buf = stransi($buf);
-
- $buf .= $text;
- $buf .= "\n";
- if ($is_error && $config['debug_backtrace']) {
- $buf .= $bt."\n";
- }
-
- $debug_file = $this->getDebugFile();
-
- $logdir = dirname($debug_file);
- if (!file_exists($logdir)) {
- mkdir($logdir);
- setperm($logdir);
- }
-
- $f = fopen($debug_file, 'a');
- if ($f) {
- fwrite($f, $buf);
- fclose($f);
- }
- }
-
- $this->reportRecursionLevel--;
- }
-
- public function canReport() {
- return $this->reportRecursionLevel < 2;
- }
-
- public function setErrorsStoreType($errorsStoreType) {
- $this->errorsStoreType = $errorsStoreType;
- }
-
- public function setMessagesStoreType($messagesStoreType) {
- $this->messagesStoreType = $messagesStoreType;
- }
-
- public function checkMessagesStoreType($store_type) {
- return $this->messagesStoreType == $store_type || $this->messagesStoreType == self::STORE_BOTH;
- }
-
- public function checkErrorsStoreType($store_type) {
- return $this->errorsStoreType == $store_type || $this->errorsStoreType == self::STORE_BOTH;
- }
-
- public function overrideDebugFile($file) {
- $this->overridenDebugFile = $file;
- }
-
- protected function getDebugFile() {
- global $config;
- return is_null($this->overridenDebugFile) ? ROOT.'/'.$config['debug_file'] : $this->overridenDebugFile;
- }
-
- public function setSilence($silent) {
- $this->silent = $silent;
- }
-
- public function setPrefix($prefix) {
- $this->prefix = $prefix;
- }
-
- public static function errname($errno) {
- static $errors = null;
- if (is_null($errors)) {
- $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true));
- }
- return $errors[$errno];
- }
-
- public static function getTypes() {
- return self::$Types;
- }
-
-}
-
-class debug_measure {
-
- private $name;
- private $time;
- private $started = false;
-
- /**
- * @param string $name
- * @return $this
- */
- public function start($name = null) {
- if (is_null($name)) {
- $name = strgen(3);
- }
- $this->name = $name;
- $this->time = microtime(true);
- $this->started = true;
- return $this;
- }
-
- /**
- * @return float|string|null
- */
- public function finish() {
- if (!$this->started) {
- debugLog("debug_measure::finish(): not started, name=".$this->name);
- return null;
- }
-
- $time = (microtime(true) - $this->time);
- debugLog("MEASURE".($this->name != '' ? ' '.$this->name : '').": ".$time);
-
- $this->started = false;
- return $time;
- }
-
-}
-
-/**
- * @param $var
- * @return string
- */
-function str_print_r($var) {
- ob_start();
- print_r($var);
- return trim(ob_get_clean());
-}
-
-/**
- * @param $var
- * @return string
- */
-function str_var_dump($var) {
- ob_start();
- var_dump($var);
- return trim(ob_get_clean());
-}
-
-/**
- * @param $args
- * @param bool $all_dump
- * @return string
- */
-function str_vars($args, $all_dump = false) {
- return implode(' ', array_map(function($a) use ($all_dump) {
- if ($all_dump) {
- return str_var_dump($a);
- }
- $type = gettype($a);
- if ($type == 'string' || $type == 'integer' || $type == 'double') {
- return $a;
- } else if ($type == 'array' || $type == 'object') {
- return str_print_r($a);
- } else {
- return str_var_dump($a);
- }
- }, $args));
-}
-
-/**
- * @param int $shift
- * @return string
- */
-function backtrace($shift = 0) {
- $bt = debug_backtrace();
- $lines = [];
- foreach ($bt as $i => $t) {
- if ($i < $shift) {
- continue;
- }
- if (!isset($t['file'])) {
- $lines[] = 'from ?';
- } else {
- $lines[] = 'from '.$t['file'].':'.$t['line'];
- }
- }
- return implode("\n", $lines);
-}
-
-/**
- * @param mixed ...$args
- */
-function debugLog(...$args) {
- global $config;
- if (!$config['is_dev'])
- return;
-
- debug::getInstance()->report(false, str_vars($args));
-}
-
-function debugLogOnProd(...$args) {
- debug::getInstance()->report(false, str_vars($args));
-}
-
-/**
- * @param mixed ...$args
- */
-function debugError(...$args) {
- $debug = debug::getInstance();
- if ($debug->canReport()) {
- $debug->report(true, str_vars($args));
- }
-}
diff --git a/localwebsite/engine/model.php b/localwebsite/engine/model.php
deleted file mode 100644
index 4dd981c..0000000
--- a/localwebsite/engine/model.php
+++ /dev/null
@@ -1,243 +0,0 @@
-<?php
-
-abstract class model {
-
- const DB_TABLE = null;
- const DB_KEY = 'id';
-
- const STRING = 0;
- const INTEGER = 1;
- const FLOAT = 2;
- const ARRAY = 3;
- const BOOLEAN = 4;
- const JSON = 5;
- const SERIALIZED = 6;
-
- protected static array $SpecCache = [];
-
- public static function create_instance(...$args) {
- $cl = get_called_class();
- return new $cl(...$args);
- }
-
- public function __construct(array $raw) {
- if (!isset(self::$SpecCache[static::class])) {
- list($fields, $model_name_map, $db_name_map) = static::get_spec();
- self::$SpecCache[static::class] = [
- 'fields' => $fields,
- 'model_name_map' => $model_name_map,
- 'db_name_map' => $db_name_map
- ];
- }
-
- foreach (self::$SpecCache[static::class]['fields'] as $field)
- $this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]);
-
- if (is_null(static::DB_TABLE))
- trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined');
- }
-
- /**
- * @param $fields
- *
- * TODO: support adding or subtracting (SET value=value+1)
- */
- public function edit($fields) {
- $db = getDB();
-
- $model_upd = [];
- $db_upd = [];
-
- foreach ($fields as $name => $value) {
- $index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null;
- if (is_null($index)) {
- debugError(__METHOD__.': field `'.$name.'` not found in '.static::class);
- continue;
- }
-
- $field = self::$SpecCache[static::class]['fields'][$index];
- switch ($field['type']) {
- case self::ARRAY:
- if (is_array($value)) {
- $db_upd[$name] = implode(',', $value);
- $model_upd[$field['model_name']] = $value;
- } else {
- debugError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.');
- }
- break;
-
- case self::INTEGER:
- $value = (int)$value;
- $db_upd[$name] = $value;
- $model_upd[$field['model_name']] = $value;
- break;
-
- case self::FLOAT:
- $value = (float)$value;
- $db_upd[$name] = $value;
- $model_upd[$field['model_name']] = $value;
- break;
-
- case self::BOOLEAN:
- $db_upd[$name] = $value ? 1 : 0;
- $model_upd[$field['model_name']] = $value;
- break;
-
- case self::JSON:
- $db_upd[$name] = jsonEncode($value);
- $model_upd[$field['model_name']] = $value;
- break;
-
- case self::SERIALIZED:
- $db_upd[$name] = serialize($value);
- $model_upd[$field['model_name']] = $value;
- break;
-
- default:
- $value = (string)$value;
- $db_upd[$name] = $value;
- $model_upd[$field['model_name']] = $value;
- break;
- }
- }
-
- if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) {
- debugError(__METHOD__.': failed to update database');
- return;
- }
-
- if (!empty($model_upd)) {
- foreach ($model_upd as $name => $value)
- $this->{$name} = $value;
- }
- }
-
- public function get_id() {
- return $this->{to_camel_case(static::DB_KEY)};
- }
-
- public function as_array(array $fields = [], array $custom_getters = []): array {
- if (empty($fields))
- $fields = array_keys(static::$SpecCache[static::class]['db_name_map']);
-
- $array = [];
- foreach ($fields as $field) {
- if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
- $array[$field] = $custom_getters[$field]();
- } else {
- $array[$field] = $this->{to_camel_case($field)};
- }
- }
-
- return $array;
- }
-
- protected static function cast_to_type(int $type, $value) {
- switch ($type) {
- case self::BOOLEAN:
- return (bool)$value;
-
- case self::INTEGER:
- return (int)$value;
-
- case self::FLOAT:
- return (float)$value;
-
- case self::ARRAY:
- return array_filter(explode(',', $value));
-
- case self::JSON:
- $val = jsonDecode($value);
- if (!$val)
- $val = null;
- return $val;
-
- case self::SERIALIZED:
- $val = unserialize($value);
- if ($val === false)
- $val = null;
- return $val;
-
- default:
- return (string)$value;
- }
- }
-
- protected static function get_spec(): array {
- $rc = new ReflectionClass(static::class);
- $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
-
- $list = [];
- $index = 0;
-
- $model_name_map = [];
- $db_name_map = [];
-
- foreach ($props as $prop) {
- if ($prop->isStatic())
- continue;
-
- $name = $prop->getName();
- if (startsWith($name, '_'))
- continue;
-
- $type = $prop->getType();
- $phpdoc = $prop->getDocComment();
-
- $mytype = null;
- if (!$prop->hasType() && !$phpdoc)
- $mytype = self::STRING;
- else {
- $typename = $type->getName();
- switch ($typename) {
- case 'string':
- $mytype = self::STRING;
- break;
- case 'int':
- $mytype = self::INTEGER;
- break;
- case 'float':
- $mytype = self::FLOAT;
- break;
- case 'array':
- $mytype = self::ARRAY;
- break;
- case 'bool':
- $mytype = self::BOOLEAN;
- break;
- }
-
- if ($phpdoc != '') {
- $pos = strpos($phpdoc, '@');
- if ($pos === false)
- continue;
-
- if (substr($phpdoc, $pos+1, 4) == 'json')
- $mytype = self::JSON;
- else if (substr($phpdoc, $pos+1, 5) == 'array')
- $mytype = self::ARRAY;
- else if (substr($phpdoc, $pos+1, 10) == 'serialized')
- $mytype = self::SERIALIZED;
- }
- }
-
- if (is_null($mytype))
- debugError(__METHOD__.": ".$name." is still null in ".static::class);
-
- $dbname = from_camel_case($name);
- $list[] = [
- 'type' => $mytype,
- 'model_name' => $name,
- 'db_name' => $dbname
- ];
-
- $model_name_map[$name] = $index;
- $db_name_map[$dbname] = $index;
-
- $index++;
- }
-
- return [$list, $model_name_map, $db_name_map];
- }
-
-}
diff --git a/localwebsite/engine/request_handler.php b/localwebsite/engine/request_handler.php
deleted file mode 100644
index 535e850..0000000
--- a/localwebsite/engine/request_handler.php
+++ /dev/null
@@ -1,142 +0,0 @@
-<?php
-
-abstract class request_handler {
-
- const GET = 'GET';
- const POST = 'POST';
-
- private static array $AllowedInputTypes = ['i', 'f', 'b', 'e' /* enum */];
-
- public function dispatch(string $act) {
- $method = $_SERVER['REQUEST_METHOD'] == 'POST' ? 'POST' : 'GET';
- return $this->call_act($method, $act);
- }
-
- protected function before_dispatch(string $method, string $act)/*: ?array*/ {
- return null;
- }
-
- protected function call_act(string $method, string $act, array $input = []) {
- global $RouterInput;
-
- $notfound = !method_exists($this, $method.'_'.$act) || !((new ReflectionMethod($this, $method.'_'.$act))->isPublic());
- if ($notfound)
- $this->method_not_found($method, $act);
-
- if (!empty($input)) {
- foreach ($input as $k => $v)
- $RouterInput[$k] = $v;
- }
-
- $args = $this->before_dispatch($method, $act);
- return call_user_func_array([$this, $method.'_'.$act], is_array($args) ? [$args] : []);
- }
-
- abstract protected function method_not_found(string $method, string $act);
-
- protected function input(string $input, bool $as_assoc = false): array {
- $input = preg_split('/,\s+?/', $input, null, PREG_SPLIT_NO_EMPTY);
-
- $ret = [];
- foreach ($input as $var) {
- list($type, $name, $enum_values, $enum_default) = self::parse_input_var($var);
-
- $value = param($name);
-
- switch ($type) {
- case 'i':
- if (is_null($value) && !is_null($enum_default)) {
- $value = (int)$enum_default;
- } else {
- $value = (int)$value;
- }
- break;
-
- case 'f':
- if (is_null($value) && !is_null($enum_default)) {
- $value = (float)$enum_default;
- } else {
- $value = (float)$value;
- }
- break;
-
- case 'b':
- if (is_null($value) && !is_null($enum_default)) {
- $value = (bool)$enum_default;
- } else {
- $value = (bool)$value;
- }
- break;
-
- case 'e':
- if (!in_array($value, $enum_values)) {
- $value = !is_null($enum_default) ? $enum_default : '';
- }
- break;
- }
-
- if (!$as_assoc) {
- $ret[] = $value;
- } else {
- $ret[$name] = $value;
- }
- }
-
- return $ret;
- }
- protected static function parse_input_var(string $var): array {
- $type = null;
- $name = null;
- $enum_values = null;
- $enum_default = null;
-
- $pos = strpos($var, ':');
- if ($pos !== false) {
- $type = substr($var, 0, $pos);
- $rest = substr($var, $pos+1);
-
- if (!in_array($type, self::$AllowedInputTypes)) {
- trigger_error('request_handler::parse_input_var('.$var.'): unknown type '.$type);
- $type = null;
- }
-
- switch ($type) {
- case 'e':
- $br_from = strpos($rest, '(');
- $br_to = strpos($rest, ')');
-
- if ($br_from === false || $br_to === false) {
- trigger_error('request_handler::parse_input_var('.$var.'): failed to parse enum values');
- $type = null;
- $name = $rest;
- break;
- }
-
- $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from+1, $br_to-$br_from-1))));
- $name = trim(substr($rest, 0, $br_from));
-
- if (!empty($enum_values)) foreach ($enum_values as $key => $val) {
- if (substr($val, 0, 1) == '=') {
- $enum_values[$key] = substr($val, 1);
- $enum_default = $enum_values[$key];
- }
- }
- break;
-
- default:
- if (($eq_pos = strpos($rest, '=')) !== false) {
- $enum_default = substr($rest, $eq_pos+1);
- $rest = substr($rest, 0, $eq_pos);
- }
- $name = trim($rest);
- break;
- }
- } else {
- $type = 's';
- $name = $var;
- }
-
- return [$type, $name, $enum_values, $enum_default];
- }
-
-}
diff --git a/localwebsite/engine/router.php b/localwebsite/engine/router.php
deleted file mode 100644
index 5e966a9..0000000
--- a/localwebsite/engine/router.php
+++ /dev/null
@@ -1,199 +0,0 @@
-<?php
-
-class router {
-
- protected array $routes = [
- 'children' => [],
- 're_children' => []
- ];
-
- public function add($template, $value) {
- if ($template == '') {
- return;
- }
-
- // expand {enum,erat,ions}
- $templates = [[$template, $value]];
- if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
- foreach ($matches[1] as $match_index => $variants) {
- $variants = explode(',', $variants);
- $variants = array_map('trim', $variants);
- $variants = array_filter($variants, function($s) { return $s != ''; });
-
- for ($i = 0; $i < count($templates); ) {
- list($template, $value) = $templates[$i];
- $new_templates = [];
- foreach ($variants as $variant_index => $variant) {
- $new_templates[] = [
- str_replace_once($matches[0][$match_index], $variant, $template),
- str_replace('${'.($match_index+1).'}', $variant, $value)
- ];
- }
- array_splice($templates, $i, 1, $new_templates);
- $i += count($new_templates);
- }
- }
- }
-
- // process all generated routes
- foreach ($templates as $template) {
- list($template, $value) = $template;
-
- $start_pos = 0;
- $parent = &$this->routes;
- $template_len = strlen($template);
-
- while ($start_pos < $template_len) {
- $slash_pos = strpos($template, '/', $start_pos);
- if ($slash_pos !== false) {
- $part = substr($template, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
- } else {
- $part = substr($template, $start_pos);
- $start_pos = $template_len;
- }
-
- $parent = &$this->_addRoute($parent, $part,
- $start_pos < $template_len ? null : $value);
- }
- }
- }
-
- protected function &_addRoute(&$parent, $part, $value = null) {
- $par_pos = strpos($part, '(');
- $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
-
- $children_key = !$is_regex ? 'children' : 're_children';
-
- if (isset($parent[$children_key][$part])) {
- if (is_null($value)) {
- $parent = &$parent[$children_key][$part];
- } else {
- if (!isset($parent[$children_key][$part]['value'])) {
- $parent[$children_key][$part]['value'] = $value;
- } else {
- trigger_error(__METHOD__.': route is already defined');
- }
- }
- return $parent;
- }
-
- $child = [
- 'children' => [],
- 're_children' => []
- ];
- if (!is_null($value)) {
- $child['value'] = $value;
- }
-
- $parent[$children_key][$part] = $child;
- return $parent[$children_key][$part];
- }
-
- public function find($uri) {
- if ($uri != '/' && $uri[0] == '/') {
- $uri = substr($uri, 1);
- }
- $start_pos = 0;
- $parent = &$this->routes;
- $uri_len = strlen($uri);
- $matches = [];
-
- while ($start_pos < $uri_len) {
- $slash_pos = strpos($uri, '/', $start_pos);
- if ($slash_pos !== false) {
- $part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
- } else {
- $part = substr($uri, $start_pos);
- $start_pos = $uri_len;
- }
-
- $found = false;
- if (isset($parent['children'][$part])) {
- $parent = &$parent['children'][$part];
- $found = true;
- } else if (!empty($parent['re_children'])) {
- foreach ($parent['re_children'] as $re => &$child) {
- $exp = '#^'.$re.'$#';
- $re_result = preg_match($exp, $part, $match);
- if ($re_result === false) {
- debugError(__METHOD__.": regex $exp failed");
- continue;
- }
-
- if ($re_result) {
- if (count($match) > 1) {
- $matches = array_merge($matches, array_slice($match, 1));
- }
- $parent = &$child;
- $found = true;
- break;
- }
- }
- }
-
- if (!$found) {
- return false;
- }
- }
-
- if (!isset($parent['value'])) {
- return false;
- }
-
- $value = $parent['value'];
- if (!empty($matches)) {
- foreach ($matches as $i => $match) {
- $needle = '$('.($i+1).')';
- $pos = strpos($value, $needle);
- if ($pos !== false) {
- $value = substr_replace($value, $match, $pos, strlen($needle));
- }
- }
- }
-
- return $value;
- }
-
- public function load($routes) {
- $this->routes = $routes;
- }
-
- public function dump() {
- return $this->routes;
- }
-
-}
-
-function routerFind(router $router) {
- $document_uri = $_SERVER['REQUEST_URI'];
- if (($pos = strpos($document_uri, '?')) !== false)
- $document_uri = substr($document_uri, 0, $pos);
- $document_uri = urldecode($document_uri);
-
- $fixed_document_uri = preg_replace('#/+#', '/', $document_uri);
- if ($fixed_document_uri != $document_uri && !is_xhr_request()) {
- redirect($fixed_document_uri);
- } else {
- $document_uri = $fixed_document_uri;
- }
-
- $route = $router->find($document_uri);
- if ($route === false)
- return false;
-
- $route = preg_split('/ +/', $route);
- $handler = $route[0];
- $act = $route[1];
- $input = [];
- if (count($route) > 2) {
- for ($i = 2; $i < count($route); $i++) {
- $var = $route[$i];
- list($k, $v) = explode('=', $var);
- $input[trim($k)] = trim($v);
- }
- }
-
- return [$handler, $act, $input];
-} \ No newline at end of file
diff --git a/localwebsite/engine/tpl.php b/localwebsite/engine/tpl.php
deleted file mode 100644
index 3d18c9a..0000000
--- a/localwebsite/engine/tpl.php
+++ /dev/null
@@ -1,520 +0,0 @@
-<?php
-
-abstract class base_tpl {
-
- public $twig;
- protected $vars = [];
- protected $global_vars = [];
- protected $title = '';
- protected $title_modifiers = [];
- protected $keywords = '';
- protected $description = '';
- protected $js = [];
- protected $lang_keys = [];
- protected $static = [];
- protected $external_static = [];
- protected $head = [];
- protected $globals_applied = false;
- protected $static_time;
-
- public function __construct($templates_dir, $cache_dir) {
- global $config;
-
- // $cl = get_called_class();
-
- $this->twig = self::twig_instance($templates_dir, $cache_dir, $config['is_dev']);
- $this->static_time = time();
- }
-
- public static function twig_instance($templates_dir, $cache_dir, $auto_reload) {
- // must specify a second argument ($rootPath) here
- // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code
- // this is bad for templates rebuilding
- $twig_loader = new \Twig\Loader\FilesystemLoader($templates_dir, ROOT);
-
- $env_options = [];
- if (!is_null($cache_dir)) {
- $env_options += [
- 'cache' => $cache_dir,
- 'auto_reload' => $auto_reload
- ];
- }
-
- $twig = new \Twig\Environment($twig_loader, $env_options);
- $twig->addExtension(new Twig_MyExtension);
-
- return $twig;
- }
-
- public function render($template, array $vars = []) {
- $this->apply_globals();
- return $this->do_render($template, array_merge($this->vars, $vars));
- }
-
- protected function do_render($template, $vars) {
- global $config;
- $s = '';
- try {
- $s = $this->twig->render($template, $vars);
- } catch (\Twig\Error\Error $e) {
- $error = get_class($e).": failed to render";
- $source_ctx = $e->getSourceContext();
- if ($source_ctx) {
- $path = $source_ctx->getPath();
- if (startsWith($path, ROOT))
- $path = substr($path, strlen(ROOT)+1);
- $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine();
- }
- $error .= ": ";
- $error .= $e->getMessage();
- debugError($error);
- if ($config['is_dev'])
- $s = $error."\n";
- }
- return $s;
- }
-
- public function set($arg1, $arg2 = null) {
- if (is_array($arg1)) {
- foreach ($arg1 as $key => $value) {
- $this->vars[$key] = $value;
- }
- } elseif ($arg2 !== null) {
- $this->vars[$arg1] = $arg2;
- }
- }
-
- public function is_set($key): bool {
- return isset($this->vars[$key]);
- }
-
- public function set_global($arg1, $arg2 = null) {
- if (is_array($arg1)) {
- foreach ($arg1 as $key => $value) {
- $this->global_vars[$key] = $value;
- }
- } elseif ($arg2 !== null) {
- $this->global_vars[$arg1] = $arg2;
- }
- }
-
- public function is_global_set($key): bool {
- return isset($this->global_vars[$key]);
- }
-
- public function get_global($key) {
- return $this->is_global_set($key) ? $this->global_vars[$key] : null;
- }
-
- public function apply_globals() {
- if (!empty($this->global_vars) && !$this->globals_applied) {
- foreach ($this->global_vars as $key => $value)
- $this->twig->addGlobal($key, $value);
- $this->globals_applied = true;
- }
- }
-
- /**
- * @param string $title
- */
- public function set_title($title) {
- $this->title = $title;
- }
-
- /**
- * @return string
- */
- public function get_title() {
- $title = $this->title != '' ? $this->title : 'Домашний сайт';
- if (!empty($this->title_modifiers)) {
- foreach ($this->title_modifiers as $modifier) {
- $title = $modifier($title);
- }
- }
- return $title;
- }
-
- /**
- * @param callable $callable
- */
- public function add_page_title_modifier(callable $callable) {
- if (!is_callable($callable)) {
- trigger_error(__METHOD__.': argument is not callable');
- } else {
- $this->title_modifiers[] = $callable;
- }
- }
-
- /**
- * @param string $css_name
- * @param null $extra
- */
- public function add_static(string $name, $extra = null) {
- global $config;
- // $is_css = endsWith($name, '.css');
- $this->static[] = [$name, $extra];
- }
-
- public function add_external_static($type, $url) {
- $this->external_static[] = ['type' => $type, 'url' => $url];
- }
-
- public function add_js($js) {
- $this->js[] = $js;
- }
-
- public function add_lang_keys(array $keys) {
- $this->lang_keys = array_merge($this->lang_keys, $keys);
- }
-
- public function add_head($html) {
- $this->head[] = $html;
- }
-
- public function get_head_html() {
- global $config;
- $lines = [];
- $public_path = $config['static_public_path'];
- foreach ($this->static as $val) {
- list($name, $extra) = $val;
- if (endsWith($name, '.js'))
- $lines[] = self::js_link($public_path.'/'.$name, $config['static'][$name] ?? 1);
- else
- $lines[] = self::css_link($public_path.'/'.$name, $config['static'][$name] ?? 1, $extra);
- }
- if (!empty($this->external_static)) {
- foreach ($this->external_static as $ext) {
- if ($ext['type'] == 'js')
- $lines[] = self::js_link($ext['url']);
- else if ($ext['type'] == 'css')
- $lines[] = self::css_link($ext['url']);
- }
- }
- if (!empty($this->head)) {
- $lines = array_merge($lines, $this->head);
- }
- return implode("\n", $lines);
- }
-
- public static function js_link($name, $version = null): string {
- if ($version !== null)
- $name .= '?'.$version;
- return '<script src="'.$name.'" type="text/javascript"></script>';
- }
-
- public static function css_link($name, $version = null, $extra = null) {
- if ($version !== null)
- $name .= '?'.$version;
- $s = '<link';
- if (is_array($extra)) {
- if (!empty($extra['id']))
- $s .= ' id="'.$extra['id'].'"';
- }
- $s .= ' rel="stylesheet" type="text/css"';
- if (is_array($extra) && !empty($extra['media']))
- $s .= ' media="'.$extra['media'].'"';
- $s .= ' href="'.$name.'"';
- $s .= '>';
- return $s;
- }
-
- public function get_lang_keys() {
- global $lang;
- $keys = [];
- if (!empty($this->lang_keys)) {
- foreach ($this->lang_keys as $key)
- $keys[$key] = $lang[$key];
- }
- return $keys;
- }
-
- public function render_not_found() {
- http_response_code(404);
- if (!is_xhr_request()) {
- $this->render_page('404.twig');
- } else {
- ajax_error(['code' => 404]);
- }
- }
-
- /**
- * @param null|string $reason
- */
- public function render_forbidden($reason = null) {
- http_response_code(403);
- if (!is_xhr_request()) {
- $this->set(['reason' => $reason]);
- $this->render_page('403.twig');
- } else {
- $data = ['code' => 403];
- if (!is_null($reason))
- $data['reason'] = $reason;
- ajax_error($data);
- }
- }
-
- public function must_revalidate() {
- header('Cache-Control: no-store, no-cache, must-revalidate');
- }
-
- abstract public function render_page($template);
-
-}
-
-class web_tpl extends base_tpl {
-
- protected $alternate = false;
-
- public function __construct() {
- global $config;
- $templates = $config['templates']['web'];
- parent::__construct(
- ROOT.'/'. $templates['root'],
- $config['twig_cache']
- ? ROOT.'/'.$templates['cache']
- : null
- );
- }
-
- public function set_alternate($alt) {
- $this->alternate = $alt;
- }
-
- public function render_page($template) {
- echo $this->_render_header();
- echo $this->_render_body($template);
- echo $this->_render_footer();
- exit;
- }
-
- public function _render_header() {
- global $config;
- $this->apply_globals();
-
- $vars = [
- 'title' => $this->get_title(),
- 'keywords' => $this->keywords,
- 'description' => $this->description,
- 'alternate' => $this->alternate,
- 'static' => $this->get_head_html(),
- ];
- return $this->do_render('header.twig', $vars);
- }
-
- public function _render_body($template) {
- return $this->do_render($template, $this->vars);
- }
-
- public function _render_footer() {
- $exec_time = microtime(true) - START_TIME;
- $exec_time = round($exec_time, 4);
-
- $footer_vars = [
- 'exec_time' => $exec_time,
- 'js' => !empty($this->js) ? implode("\n", $this->js) : '',
- ];
- return $this->do_render('footer.twig', $footer_vars);
- }
-
-}
-
-class Twig_MyExtension extends \Twig\Extension\AbstractExtension {
-
- public function getFilters() {
- global $lang;
-
- return array(
- new \Twig\TwigFilter('lang', 'lang'),
-
- new \Twig\TwigFilter('lang', function($key, array $args = []) use (&$lang) {
- array_walk($args, function(&$item, $key) {
- $item = htmlescape($item);
- });
- array_unshift($args, $key);
- return call_user_func_array([$lang, 'get'], $args);
- }, ['is_variadic' => true]),
-
- new \Twig\TwigFilter('plural', function($text, array $args = []) use (&$lang) {
- array_unshift($args, $text);
- return call_user_func_array([$lang, 'num'], $args);
- }, ['is_variadic' => true]),
-
- new \Twig\TwigFilter('format_number', function($number, array $args = []) {
- array_unshift($args, $number);
- return call_user_func_array('formatNumber', $args);
- }, ['is_variadic' => true]),
-
- new \Twig\TwigFilter('short_number', function($number, array $args = []) {
- array_unshift($args, $number);
- return call_user_func_array('shortNumber', $args);
- }, ['is_variadic']),
-
- new \Twig\TwigFilter('format_time', function($ts, array $args = []) {
- array_unshift($args, $ts);
- return call_user_func_array('formatTime', $args);
- }, ['is_variadic' => true]),
-
- new \Twig\TwigFilter('format_duration', function($seconds, array $args = []) {
- array_unshift($args, $seconds);
- return call_user_func_array('formatDuration', $args);
- }, ['is_variadic' => true]),
- );
- }
-
- public function getTokenParsers() {
- return [new JsTagTokenParser()];
- }
-
- public function getName() {
- return 'lang';
- }
-
-}
-
-// Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback
-class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
-
- public function parse(\Twig\Token $token) {
- $lineno = $token->getLine();
- $stream = $this->parser->getStream();
-
- // recovers all inline parameters close to your tag name
- $params = array_merge([], $this->getInlineParams($token));
-
- $continue = true;
- while ($continue) {
- // create subtree until the decideJsTagFork() callback returns true
- $body = $this->parser->subparse(array ($this, 'decideJsTagFork'));
-
- // I like to put a switch here, in case you need to add middle tags, such
- // as: {% js %}, {% nextjs %}, {% endjs %}.
- $tag = $stream->next()->getValue();
- switch ($tag) {
- case 'endjs':
- $continue = false;
- break;
- default:
- throw new \Twig\Error\SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "endjs" to close the "mytag" block started at line %d)', $lineno), -1);
- }
-
- // you want $body at the beginning of your arguments
- array_unshift($params, $body);
-
- // if your endjs can also contains params, you can uncomment this line:
- // $params = array_merge($params, $this->getInlineParams($token));
- // and comment this one:
- $stream->expect(\Twig\Token::BLOCK_END_TYPE);
- }
-
- return new JsTagNode(new \Twig\Node\Node($params), $lineno, $this->getTag());
- }
-
- /**
- * Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} )
- *
- * @param \Twig\Token $token
- * @return array
- */
- protected function getInlineParams(\Twig\Token $token) {
- $stream = $this->parser->getStream();
- $params = array ();
- while (!$stream->test(\Twig\Token::BLOCK_END_TYPE)) {
- $params[] = $this->parser->getExpressionParser()->parseExpression();
- }
- $stream->expect(\Twig\Token::BLOCK_END_TYPE);
- return $params;
- }
-
- /**
- * Callback called at each tag name when subparsing, must return
- * true when the expected end tag is reached.
- *
- * @param \Twig\Token $token
- * @return bool
- */
- public function decideJsTagFork(\Twig\Token $token) {
- return $token->test(['endjs']);
- }
-
- /**
- * Your tag name: if the parsed tag match the one you put here, your parse()
- * method will be called.
- *
- * @return string
- */
- public function getTag() {
- return 'js';
- }
-
-}
-
-class JsTagNode extends \Twig\Node\Node {
-
- public function __construct($params, $lineno = 0, $tag = null) {
- parent::__construct(['params' => $params], [], $lineno, $tag);
- }
-
- public function compile(\Twig\Compiler $compiler) {
- $count = count($this->getNode('params'));
-
- $compiler->addDebugInfo($this);
- $compiler
- ->write('global $__tpl;')
- ->raw(PHP_EOL);
-
- for ($i = 0; ($i < $count); $i++) {
- // argument is not an expression (such as, a \Twig\Node\Textbody)
- // we should trick with output buffering to get a valid argument to pass
- // to the functionToCall() function.
- if (!($this->getNode('params')->getNode($i) instanceof \Twig\Node\Expression\AbstractExpression)) {
- $compiler
- ->write('ob_start();')
- ->raw(PHP_EOL);
-
- $compiler
- ->subcompile($this->getNode('params')->getNode($i));
-
- $compiler
- ->write('$js = ob_get_clean();')
- ->raw(PHP_EOL);
- }
- }
-
- $compiler
- ->write('$__tpl->add_js($js);')
- ->raw(PHP_EOL)
- ->write('unset($js);')
- ->raw(PHP_EOL);
- }
-
-}
-
-
-
-/**
- * @param $data
- */
-function ajax_ok($data) {
- ajax_response(['response' => $data]);
-}
-
-/**
- * @param $error
- * @param int $code
- */
-function ajax_error($error, $code = 200) {
- ajax_response(['error' => $error], $code);
-}
-
-/**
- * @param $data
- * @param int $code
- */
-function ajax_response($data, $code = 200) {
- header('Cache-Control: no-cache, must-revalidate');
- header('Pragma: no-cache');
- header('Content-Type: application/json; charset=utf-8');
- http_response_code($code);
- echo jsonEncode($data);
- exit;
-} \ No newline at end of file
diff --git a/localwebsite/handlers/AuthHandler.php b/localwebsite/handlers/AuthHandler.php
deleted file mode 100644
index 971f850..0000000
--- a/localwebsite/handlers/AuthHandler.php
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-class AuthHandler extends RequestHandler {
-
- protected function before_dispatch(string $method, string $act) {
- return null;
- }
-
- public function GET_auth() {
- list($error) = $this->input('error');
- $this->tpl->set(['error' => $error]);
- $this->tpl->set_title('Авторизация');
- $this->tpl->render_page('auth.twig');
- }
-
- public function POST_auth() {
- list($username, $password) = $this->input('username, password');
-
- $result = users::validatePassword($username, $password);
- if (!$result) {
- debugError('invalid login attempt: '.$_SERVER['REMOTE_ADDR'].', '.$_SERVER['HTTP_USER_AGENT'].", username=$username, password=$password");
- redirect('/auth/?error='.urlencode('неверный логин или пароль'));
- }
-
- auth::setToken(pwhash($password));
- redirect('/');
- }
-
- public function GET_deauth() {
- if (auth::id())
- auth::logout();
-
- redirect('/');
- }
-
-}
diff --git a/localwebsite/handlers/FakeRequestHandler.php b/localwebsite/handlers/FakeRequestHandler.php
deleted file mode 100644
index 09e03b8..0000000
--- a/localwebsite/handlers/FakeRequestHandler.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-class FakeRequestHandler extends RequestHandler {
-
- public function apacheNotFound() {
- http_response_code(404);
- $uri = htmlspecialchars($_SERVER['REQUEST_URI']);
- echo <<<EOF
-<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
-<html><head>
-<title>404 Not Found</title>
-</head><body>
-<h1>Not Found</h1>
-<p>The requested URL {$uri} was not found on this server.</p>
-</body></html>
-EOF;
- exit;
- }
-
-} \ No newline at end of file
diff --git a/localwebsite/handlers/MiscHandler.php b/localwebsite/handlers/MiscHandler.php
deleted file mode 100644
index 4e7291e..0000000
--- a/localwebsite/handlers/MiscHandler.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-class MiscHandler extends RequestHandler
-{
-
- public function GET_sensors_page() {
- global $config;
-
- $clients = [];
- foreach ($config['temphumd_servers'] as $key => $params) {
- $cl = new TemphumdClient(...$params);
- $clients[$key] = $cl;
-
- $cl->readSensor();
- }
-
- $this->tpl->set(['sensors' => $clients]);
- $this->tpl->set_title('Датчики');
- $this->tpl->render_page('sensors.twig');
- }
-
- public function GET_cams_stat() {
- global $config;
- list($ip, $port) = explode(':', $config['ipcam_server_api_addr']);
- $body = jsonDecode(file_get_contents('http://'.$ip.':'.$port.'/api/timestamp/all'));
-
- header('Content-Type: text/plain');
- $date_fmt = 'd.m.Y H:i:s';
-
- foreach ($body['response'] as $cam => $data) {
- $fix = date($date_fmt, $data['fix']);
- $start = date($date_fmt, $data['motion_start']);
- $motion = date($date_fmt, $data['motion']);
- echo "$cam:\n motion: $motion\n";
- echo " motion_start: $start\n";
- echo " fix: $fix\n\n";
- }
- }
-
-}
diff --git a/localwebsite/handlers/ModemHandler.php b/localwebsite/handlers/ModemHandler.php
deleted file mode 100644
index 94ad75b..0000000
--- a/localwebsite/handlers/ModemHandler.php
+++ /dev/null
@@ -1,130 +0,0 @@
-<?php
-
-use libphonenumber\NumberParseException;
-use libphonenumber\PhoneNumberFormat;
-use libphonenumber\PhoneNumberUtil;
-
-class ModemHandler extends RequestHandler
-{
-
- public function GET_routing_smallhome_page() {
- global $config;
-
- list($error) = $this->input('error');
- $upstream = self::getCurrentUpstream();
-
- $current_upstream = [
- 'key' => $upstream,
- 'label' => $config['modems'][$upstream]['label']
- ];
-
- $this->tpl->set([
- 'error' => $error,
- 'current' => $current_upstream,
- 'modems' => $config['modems'],
- ]);
- $this->tpl->set_title('Маршрутизация');
- $this->tpl->render_page('routing_page.twig');
- }
-
- public function GET_routing_smallhome_switch() {
- global $config;
- list($new_upstream) = $this->input('upstream');
- if (!isset($config['modems'][$new_upstream]))
- redirect('/routing/?error='.urlencode('invalid upstream'));
-
- $current_upstream = self::getCurrentUpstream();
- if ($current_upstream != $new_upstream) {
- if ($new_upstream == 'mts-il')
- $new_upstream_ip = '192.168.88.1';
- else
- $new_upstream_ip = $config['modems'][$new_upstream]['ip'];
- MyOpenWrtUtils::setUpstream($new_upstream_ip);
- }
-
- redirect('/routing/');
- }
-
- public function GET_routing_ipsets_page() {
- list($error) = $this->input('error');
-
- $ip_sets = MyOpenWrtUtils::ipsetListAll();
- $this->tpl->set([
- 'sets' => $ip_sets,
- 'error' => $error
- ]);
- $this->tpl->set_title('Маршрутизация: IP sets');
- $this->tpl->render_page('routing_ipsets_page.twig');
- }
-
- public function GET_routing_ipsets_del() {
- list($set, $ip) = $this->input('set, ip');
- self::validateIpsetsInput($set, $ip);
-
- $output = MyOpenWrtUtils::ipsetDel($set, $ip);
-
- $url = '/routing/ipsets/';
- if ($output != '')
- $url .= '?error='.urlencode($output);
- redirect($url);
- }
-
- public function POST_routing_ipsets_add() {
- list($set, $ip) = $this->input('set, ip');
- self::validateIpsetsInput($set, $ip);
-
- $output = MyOpenWrtUtils::ipsetAdd($set, $ip);
-
- $url = '/routing/ipsets/';
- if ($output != '')
- $url .= '?error='.urlencode($output);
- redirect($url);
- }
-
- public function GET_routing_dhcp_page() {
- $overrides = config::get('dhcp_hostname_overrides');
- $leases = MyOpenWrtUtils::getDHCPLeases();
- foreach ($leases as &$lease) {
- if ($lease['hostname'] == '?' && array_key_exists($lease['mac'], $overrides))
- $lease['hostname'] = $overrides[$lease['mac']];
- }
- $this->tpl->set([
- 'leases' => $leases
- ]);
- $this->tpl->set_title('Маршрутизация: DHCP');
- $this->tpl->render_page('routing_dhcp_page.twig');
- }
-
- protected static function getCurrentUpstream() {
- global $config;
-
- $default_route = MyOpenWrtUtils::getDefaultRoute();
- if ($default_route == '192.168.88.1')
- $default_route = $config['modems']['mts-il']['ip'];
- $upstream = null;
- foreach ($config['modems'] as $modem_name => $modem_data) {
- if ($default_route == $modem_data['ip']) {
- $upstream = $modem_name;
- break;
- }
- }
- if (is_null($upstream))
- $upstream = $config['routing_default'];
-
- return $upstream;
- }
-
- protected static function validateIpsetsInput($set, $ip) {
- global $config;
-
- if (!isset($config['modems'][$set]))
- redirect('/routing/ipsets/?error='.urlencode('invalid set: '.$set));
-
- if (($slashpos = strpos($ip, '/')) !== false)
- $ip = substr($ip, 0, $slashpos);
-
- if (!filter_var($ip, FILTER_VALIDATE_IP))
- redirect('/routing/ipsets/?error='.urlencode('invalid ip/network: '.$ip));
- }
-
-}
diff --git a/localwebsite/handlers/RequestHandler.php b/localwebsite/handlers/RequestHandler.php
deleted file mode 100644
index 136a23e..0000000
--- a/localwebsite/handlers/RequestHandler.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-class RequestHandler extends request_handler {
-
- /** @var web_tpl*/
- protected $tpl;
-
- public function __construct() {
- global $__tpl;
- $__tpl = new web_tpl();
- $this->tpl = $__tpl;
-
- $this->tpl->add_static('bootstrap.min.css');
- $this->tpl->add_static('bootstrap.min.js');
- $this->tpl->add_static('polyfills.js');
- $this->tpl->add_static('app.js');
- $this->tpl->add_static('app.css');
-
- if (auth::id()) {
- $this->tpl->set_global([
- 'auth_user' => auth::$authorizedUser
- ]);
- }
- }
-
- public function dispatch(string $act) {
- global $config;
- $this->tpl->set_global([
- '__dev' => $config['is_dev'],
- ]);
- return parent::dispatch($act);
- }
-
- protected function method_not_found(string $method, string $act)
- {
- global $config;
-
- if ($act != '404' && $config['is_dev'])
- debugError(get_called_class() . ": act {$method}_{$act} not found.");
-
- if (!is_xhr_request())
- $this->tpl->render_not_found();
- else
- ajax_error('unknown act "'.$act.'"', 404);
-
- }
-
- protected function before_dispatch(string $method, string $act) {
- if (config::get('auth_need') && !auth::id())
- redirect('/auth/');
- }
-} \ No newline at end of file
diff --git a/localwebsite/htdocs/.htaccess b/localwebsite/htdocs/.htaccess
deleted file mode 100644
index c18b135..0000000
--- a/localwebsite/htdocs/.htaccess
+++ /dev/null
@@ -1,6 +0,0 @@
-RewriteEngine on
-
-RewriteCond %{REQUEST_FILENAME} !-f
-RewriteCond %{REQUEST_FILENAME} !-d
-RewriteCond %{REQUEST_URI} !=/server-status
-RewriteRule ^.*$ /index.php [L,QSA]
diff --git a/localwebsite/htdocs/favicon.ico b/localwebsite/htdocs/favicon.ico
deleted file mode 100644
index d5ff579..0000000
--- a/localwebsite/htdocs/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/localwebsite/htdocs/index.php b/localwebsite/htdocs/index.php
deleted file mode 100644
index cd32132..0000000
--- a/localwebsite/htdocs/index.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-require_once __DIR__.'/../init.php';
-
-$router = new router;
-
-$router->add('routing/', 'Modem routing_smallhome_page');
-$router->add('routing/switch-small-home/', 'Modem routing_smallhome_switch');
-$router->add('routing/{ipsets,dhcp}/', 'Modem routing_${1}_page');
-$router->add('routing/ipsets/{add,del}/', 'Modem routing_ipsets_${1}');
-
-$router->add('sms/', 'Modem sms');
-// $router->add('modem/set.ajax', 'Modem ctl_set_ajax');
-
-// inverter
-$router->add('inverter/set-osp/', 'Inverter set_osp');
-
-// misc
-$router->add('/', 'Misc main');
-$router->add('sensors/', 'Misc sensors_page');
-$router->add('cams/', 'Misc cams');
-$router->add('cams/([\d,]+)/', 'Misc cams id=$(1)');
-$router->add('cams/stat/', 'Misc cams_stat');
-$router->add('debug/', 'Misc debug');
-
-// auth
-$router->add('auth/', 'Auth auth');
-$router->add('deauth/', 'Auth deauth');
-
-
-$route = routerFind($router);
-if ($route === false)
- (new FakeRequestHandler)->dispatch('404');
-
-list($handler, $act, $RouterInput) = $route;
-
-$handler_class = $handler.'Handler';
-if (!class_exists($handler_class)) {
- debugError('index.php: class '.$handler_class.' not found');
- (new FakeRequestHandler)->dispatch('404');
-}
-
-(new $handler_class)->dispatch($act);
diff --git a/localwebsite/templates-web/404.twig b/localwebsite/templates-web/404.twig
deleted file mode 100644
index e28f5c5..0000000
--- a/localwebsite/templates-web/404.twig
+++ /dev/null
@@ -1 +0,0 @@
-Page Not Found \ No newline at end of file
diff --git a/localwebsite/templates-web/auth.twig b/localwebsite/templates-web/auth.twig
deleted file mode 100644
index a0107b3..0000000
--- a/localwebsite/templates-web/auth.twig
+++ /dev/null
@@ -1,24 +0,0 @@
-{% include 'bc.twig' with {
- history: [
- {text: "Авторизация" }
- ]
-} %}
-
-{% if error %}
- <div class="mt-4 alert alert-danger"><b>Ошибка:</b> {{ error }}</div>
-{% endif %}
-
-
-<form method="post" action="/auth/">
- <div class="mt-2">
- <input type="text" name="username" placeholder="Логин" class="form-control">
- </div>
-
- <div class="mt-2">
- <input type="password" name="password" placeholder="Пароль" class="form-control">
- </div>
-
- <div class="mt-2">
- <button type="submit" class="btn btn-outline-primary">Войти</button>
- </div>
-</form> \ No newline at end of file
diff --git a/localwebsite/templates-web/bc.twig b/localwebsite/templates-web/bc.twig
deleted file mode 100644
index b74ad40..0000000
--- a/localwebsite/templates-web/bc.twig
+++ /dev/null
@@ -1,12 +0,0 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- {% for item in history %}
- <li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}>
- {% if item.link %}<a href="{{ item.link }}">{% endif %}
- {{ item.html ? item.html|raw : item.text }}
- {% if item.link %}</a>{% endif %}
- </li>
- {% endfor %}
- </ol>
-</nav> \ No newline at end of file
diff --git a/localwebsite/templates-web/footer.twig b/localwebsite/templates-web/footer.twig
deleted file mode 100644
index 8739f80..0000000
--- a/localwebsite/templates-web/footer.twig
+++ /dev/null
@@ -1,8 +0,0 @@
-{% if js %}
- <script>{{ js|raw }}</script>
-{% endif %}
-
-</div>
-</body>
-</html>
-<!-- generated in {{ exec_time}} --> \ No newline at end of file
diff --git a/localwebsite/templates-web/header.twig b/localwebsite/templates-web/header.twig
deleted file mode 100644
index 1a866e1..0000000
--- a/localwebsite/templates-web/header.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-<!doctype html>
-<html>
-<head>
- <title>{{ title }}</title>
- <meta http-equiv="content-type" content="text/html; charset=utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
- <script>
- window.onerror = function(error) {
- window.console && console.error(error);
- }
- </script>
- {{ static|raw }}
-</head>
-<body>
-<div class="container py-3"> \ No newline at end of file
diff --git a/localwebsite/templates-web/index.twig b/localwebsite/templates-web/index.twig
deleted file mode 100644
index b28a078..0000000
--- a/localwebsite/templates-web/index.twig
+++ /dev/null
@@ -1,35 +0,0 @@
-<div class="container py-4">
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item active" aria-current="page">Главная</li>
- </ol>
- </nav>
-
- {% if auth_user %}
- <div class="mb-4 alert alert-secondary">
- Вы авторизованы как <b>{{ auth_user.username }}</b>. <a href="/deauth/">Выйти</a>
- </div>
- {% endif %}
-
- <h6>Интернет</h6>
- <ul class="list-group list-group-flush">
- <li class="list-group-item"><a href="/modem/">Модемы</a></li>
- <li class="list-group-item"><a href="/routing/">Маршрутизация</a></li>
- <li class="list-group-item"><a href="/sms/">SMS-сообщения</a></li>
- </ul>
-
- <h6 class="mt-4">Другое</h6>
- <ul class="list-group list-group-flush">
- <li class="list-group-item"><a href="/inverter/">Инвертор</a> (<a href="/inverter/?alt=1">alt</a>, <a href="{{ grafana_inverter_url }}">Grafana</a>)</li>
- <li class="list-group-item"><a href="/pump/">Насос</a> (<a href="/pump/?alt=1">alt</a>)</li>
- <li class="list-group-item"><a href="/sensors/">Датчики</a> (<a href="{{ grafana_sensors_url }}">Grafana</a>)</li>
- </ul>
-
- <h6 class="mt-4"><a href="/cams/"><b>Все камеры</b></a> (<a href="/cams/?high=1">HQ</a>)</h6>
- <ul class="list-group list-group-flush">
- {% for id, name in cameras %}
- <li class="list-group-item"><a href="/cams/{{ id }}/">{{ name }}</a> (<a href="/cams/{{ id }}/?high=1">HQ</a>)</li>
- {% endfor %}
- <li class="list-group-item"><a href="/cams/stat/">Статистика</a></li>
- </ul>
-</div>
diff --git a/localwebsite/templates-web/routing_dhcp_page.twig b/localwebsite/templates-web/routing_dhcp_page.twig
deleted file mode 100644
index 12b837e..0000000
--- a/localwebsite/templates-web/routing_dhcp_page.twig
+++ /dev/null
@@ -1,11 +0,0 @@
-{% include 'routing_header.twig' with {
- selected_tab: 'dhcp'
-} %}
-
-{% for lease in leases %}
- <div class="mt-3">
- <b>{{ lease.hostname }}</b> <span class="text-secondary">(exp: {{ lease.time_s }})</span><br/>
- {{ lease.ip }}<br>
- <span class="text-secondary">{{ lease.mac }}</span>
- </div>
-{% endfor %} \ No newline at end of file
diff --git a/localwebsite/templates-web/routing_header.twig b/localwebsite/templates-web/routing_header.twig
deleted file mode 100644
index 7d07d0a..0000000
--- a/localwebsite/templates-web/routing_header.twig
+++ /dev/null
@@ -1,23 +0,0 @@
-{% include 'bc.twig' with {
- history: [
- {text: "Маршрутизация" }
- ]
-} %}
-
-{% set routing_tabs = [
- {tab: 'smallhome', url: '/routing/', label: 'Интернет'},
- {tab: 'ipsets', url: '/routing/ipsets/', label: 'Правила'},
- {tab: 'dhcp', url: '/routing/dhcp/', label: 'DHCP'}
-] %}
-
-<nav>
- <div class="nav nav-tabs" id="nav-tab">
- {% for tab in routing_tabs %}
- <a href="{{ tab.url }}" class="text-decoration-none"><button class="nav-link{% if tab.tab == selected_tab %} active{% endif %}" type="button">{{ tab.label }}</button></a>
- {% endfor %}
- </div>
-</nav>
-
-{% if error %}
- <div class="mt-4 alert alert-danger"><b>Ошибка:</b> {{ error }}</div>
-{% endif %}
diff --git a/localwebsite/templates-web/routing_ipsets_page.twig b/localwebsite/templates-web/routing_ipsets_page.twig
deleted file mode 100644
index 5996e68..0000000
--- a/localwebsite/templates-web/routing_ipsets_page.twig
+++ /dev/null
@@ -1,29 +0,0 @@
-{% include 'routing_header.twig' with {
- selected_tab: 'ipsets'
-} %}
-
-<div class="mt-2 text-secondary">
- Таблицы расположены в порядке применения правил iptables.
-</div>
-
-{% for set, ips in sets %}
- <h6 class="text-primary mt-4">{{ set }}</h6>
-
- {% if ips %}
- {% for ip in ips %}
- <div>{{ ip }} (<a href="/routing/ipsets/del/?set={{ set }}&amp;ip={{ ip }}" onclick="return confirm('Подтвердите удаление {{ ip }} из {{ set }}.')">удалить</a>)</div>
- {% endfor %}
- {% else %}
- <span class="text-secondary">Нет записей.</span>
- {% endif %}
-
- <div style="max-width: 300px">
- <form method="post" action="/routing/ipsets/add/">
- <input type="hidden" name="set" value="{{ set }}">
- <div class="input-group mt-2">
- <input type="text" name="ip" placeholder="x.x.x.x/y" class="form-control">
- <button type="submit" class="btn btn-outline-primary">Добавить</button>
- </div>
- </form>
- </div>
-{% endfor %} \ No newline at end of file
diff --git a/localwebsite/templates-web/routing_page.twig b/localwebsite/templates-web/routing_page.twig
deleted file mode 100644
index 90437fd..0000000
--- a/localwebsite/templates-web/routing_page.twig
+++ /dev/null
@@ -1,17 +0,0 @@
-{% include 'routing_header.twig' with {
- selected_tab: 'smallhome'
-} %}
-
-<div class="mt-3 mb-3">
- Текущий апстрим: <b>{{ current.label }}</b>
-</div>
-
-{% for key, modem in modems %}
- {% if key != current.key %}
- <div class="pt-1 pb-2">
- <a href="/routing/switch-small-home/?upstream={{ key }}">
- <button type="button" class="btn btn-primary">Переключить на <b>{{ modem.label }}</b></button>
- </a>
- </div>
- {% endif %}
-{% endfor %} \ No newline at end of file
diff --git a/localwebsite/templates-web/sensors.twig b/localwebsite/templates-web/sensors.twig
deleted file mode 100644
index 1005dc0..0000000
--- a/localwebsite/templates-web/sensors.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-{% include 'bc.twig' with {
- history: [
- {text: "Датчики" }
- ]
-} %}
-
-{% for key, sensor in sensors %}
- <h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ sensor.name }}</h6>
- {% if sensor.hasTemperature() %}
- <span class="text-secondary">Температура:</span> <b>{{ sensor.temp }}</b> °C<br>
- {% endif %}
- {% if sensor.hasHumidity() %}
- <span class="text-secondary">Влажность:</span> <b>{{ sensor.humidity }}</b>%
- {% endif %}
-{% endfor %}
diff --git a/web/kbn_assets/error_page.css b/web/kbn_assets/error_page.css
new file mode 100644
index 0000000..c39f932
--- /dev/null
+++ b/web/kbn_assets/error_page.css
@@ -0,0 +1,30 @@
+body, html {
+ margin: 0;
+ padding: 0;
+}
+body {
+ background-color: #f9cfcf;
+ padding: 15px;
+}
+.error_title {
+ font-size: 24px;
+ color: #5d1b1b;
+}
+.error_message {
+ color: #000;
+ margin-top: 10px;
+ font-size: 20px;
+}
+.error_traceback {
+ margin-top: 10px;
+}
+.error_traceback .error_title {
+ margin-top: 15px;
+}
+.error_traceback_content {
+ font-family: monospace;
+ display: block;
+ white-space: pre-wrap;
+ overflow-x: auto;
+ margin: 1em 0;
+} \ No newline at end of file
diff --git a/web/kbn_templates/base.j2 b/web/kbn_templates/base.j2
index dc1fd58..53a2984 100644
--- a/web/kbn_templates/base.j2
+++ b/web/kbn_templates/base.j2
@@ -1,7 +1,7 @@
{% macro breadcrumbs(history) %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="main.cgi">{{ "main"|lang }}</a></li>
+ <li class="breadcrumb-item"><a href="/main.cgi">{{ "main"|lang }}</a></li>
{% for item in history %}
<li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}>
{% if item.link %}<a href="{{ item.link }}">{% endif %}
diff --git a/web/kbn_templates/index.j2 b/web/kbn_templates/index.j2
index e246dc5..cd40819 100644
--- a/web/kbn_templates/index.j2
+++ b/web/kbn_templates/index.j2
@@ -17,15 +17,15 @@
<h6>{{ "internet"|lang }}</h6>
<ul class="list-group list-group-flush">
<li class="list-group-item"><a href="/modems.cgi">{{ "modems"|lang }}</a></li>
- <li class="list-group-item"><a href="/routing.cgi">{{ "routing"|lang }}</a></li>
+ <li class="list-group-item"><a href="/routing/main.cgi">{{ "routing"|lang }}</a></li>
<li class="list-group-item"><a href="/sms.cgi">{{ "sms"|lang }}</a></li>
</ul>
<h6 class="mt-4">{{ "misc"|lang }}</h6>
<ul class="list-group list-group-flush">
<li class="list-group-item"><a href="/inverter.cgi">{{ "inverter"|lang }}</a> (<a href="{{ inverter_grafana_url }}">Grafana</a>)</li>
- <li class="list-group-item"><a href="/pump.cgi">{{ "pump"|lang }}</a></li>
- <li class="list-group-item"><a href="/sensors.cgi">{{ "sensors"|lang }}</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li>
+{# <li class="list-group-item"><a href="/pump.cgi">{{ "pump"|lang }}</a></li>#}
+{# <li class="list-group-item"><a href="/sensors.cgi">{{ "sensors"|lang }}</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li>#}
</ul>
<nav class="mt-4">
diff --git a/web/kbn_templates/routing_dhcp.j2 b/web/kbn_templates/routing_dhcp.j2
new file mode 100644
index 0000000..c8a3e8e
--- /dev/null
+++ b/web/kbn_templates/routing_dhcp.j2
@@ -0,0 +1,14 @@
+{% extends "base.j2" %}
+
+{% block content %}
+{% include 'routing_header.j2' %}
+
+{% for lease in leases %}
+ <div class="mt-3">
+ <b>{{ lease.hostname }}</b> <span class="text-secondary">(exp: {{ lease.time_s }})</span><br/>
+ {{ lease.ip }}<br>
+ <span class="text-secondary">{{ lease.mac }}</span>
+ </div>
+{% endfor %}
+
+{% endblock %} \ No newline at end of file
diff --git a/web/kbn_templates/routing_header.j2 b/web/kbn_templates/routing_header.j2
new file mode 100644
index 0000000..527f154
--- /dev/null
+++ b/web/kbn_templates/routing_header.j2
@@ -0,0 +1,19 @@
+{{ breadcrumbs([{'text': 'routing'|lang}]) }}
+
+{% set routing_tabs = [
+ {'tab': 'main', 'url': '/routing/main.cgi', 'label': 'routing_main'|lang},
+ {'tab': 'rules', 'url': '/routing/rules.cgi', 'label': 'routing_rules'|lang},
+ {'tab': 'dhcp', 'url': '/routing/dhcp.cgi', 'label': 'DHCP'}
+] %}
+
+<nav>
+ <div class="nav nav-tabs" id="nav-tab">
+ {% for tab in routing_tabs %}
+ <a href="{{ tab.url }}" class="text-decoration-none"><button class="nav-link{% if tab.tab == selected_tab %} active{% endif %}" type="button">{{ tab.label }}</button></a>
+ {% endfor %}
+ </div>
+</nav>
+
+{% if error %}
+ <div class="mt-4 alert alert-danger"><b>{{ "error"|lang }}:</b> {{ error }}</div>
+{% endif %}
diff --git a/web/kbn_templates/routing_main.j2 b/web/kbn_templates/routing_main.j2
new file mode 100644
index 0000000..856da78
--- /dev/null
+++ b/web/kbn_templates/routing_main.j2
@@ -0,0 +1,19 @@
+{% extends "base.j2" %}
+
+{% block content %}
+{% include 'routing_header.j2' %}
+
+<div class="mt-3 mb-3">
+ {{ "routing_current_upstream"|lang }}: <b>{{ (upstream|lang('modems'))['full'] }}</b>
+</div>
+
+{% for modem in modems %}
+ {% if modem != upstream %}
+ <div class="pt-1 pb-2">
+ <a href="/routing/main.cgi?set-upstream-to={{ modem }}">
+ <button type="button" class="btn btn-primary">{{ "routing_switch_to"|lang }} <b>{{ (modem|lang('modems'))['full'] }}</b></button>
+ </a>
+ </div>
+ {% endif %}
+{% endfor %}
+{% endblock %} \ No newline at end of file
diff --git a/web/kbn_templates/routing_rules.j2 b/web/kbn_templates/routing_rules.j2
new file mode 100644
index 0000000..61b68c9
--- /dev/null
+++ b/web/kbn_templates/routing_rules.j2
@@ -0,0 +1,30 @@
+{% extends "base.j2" %}
+
+{% block content %}
+{% include 'routing_header.j2' %}
+
+<div class="mt-2 text-secondary">{{ "routing_iptables_note"|lang }}</div>
+
+{% for set, ips in sets.items() %}
+ <h6 class="text-primary mt-4">{{ set }}</h6>
+
+ {% if ips %}
+ {% for ip in ips %}
+ <div>{{ ip }} (<a href="/routing/rules.cgi?action=del&amp;set={{ set }}&amp;ip={{ ip }}" onclick="return confirm('{{ 'routing_deleting_confirmation'|lang|format(ip, set) }}')">{{ "routing_del"|lang }}</a>)</div>
+ {% endfor %}
+ {% else %}
+ <span class="text-secondary">{{ "routing_no_records"|lang }}</span>
+ {% endif %}
+
+ <div style="max-width: 300px">
+ <form method="get" action="/routing/rules.cgi">
+ <input type="hidden" name="action" value="add">
+ <input type="hidden" name="set" value="{{ set }}">
+ <div class="input-group mt-2">
+ <input type="text" name="ip" placeholder="x.x.x.x/y" class="form-control">
+ <button type="submit" class="btn btn-outline-primary">{{ "routing_add"|lang }}</button>
+ </div>
+ </form>
+ </div>
+{% endfor %}
+{% endblock %} \ No newline at end of file