web_kbn: almost completely ported lws to python

This commit is contained in:
Evgeny Zinoviev 2024-02-19 01:44:02 +03:00
parent d79309e498
commit 3741f7cf78
52 changed files with 711 additions and 2748 deletions

View File

@ -2,6 +2,7 @@
import logging import logging
import __py_include import __py_include
from aiohttp import web
from homekit import http from homekit import http
from homekit.config import config, AppConfigUnit from homekit.config import config, AppConfigUnit
from homekit.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig from homekit.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig
@ -15,7 +16,6 @@ mqtt: Optional[MqttWrapper] = None
mqtt_nodes: dict[str, MqttNode] = {} mqtt_nodes: dict[str, MqttNode] = {}
relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {} relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {}
relay_states: dict[str, MqttRelayState] = {} relay_states: dict[str, MqttRelayState] = {}
mqtt_nodes_config = MqttNodesConfig() mqtt_nodes_config = MqttNodesConfig()
@ -67,41 +67,47 @@ def on_mqtt_message(node: MqttNode,
relay_states[node.id].update(**kwargs) relay_states[node.id].update(**kwargs)
class RelayMqttHttpProxy(http.HTTPServer): # -=-=-=-=-=-=- #
def __init__(self, *args, **kwargs): # Web interface #
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)
async def _relay_on_off(self, routes = web.RouteTableDef()
enable: Optional[bool],
req: http.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: async def _relay_on_off(self,
if node_id in relay_states and relay_states[node_id].ever_updated: enable: Optional[bool],
cur_state = relay_states[node_id].enabled req: web.Request):
else: node_id = req.match_info['id']
cur_state = False node_secret = req.query['secret']
enable = not cur_state
node.secret = node_secret node = mqtt_nodes[node_id]
relay_module.switchpower(enable) relay_module = relay_modules[node_id]
return self.ok()
async def relay_on(self, req: http.Request): if enable is None:
return await self._relay_on_off(True, req) 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_off(self, req: http.Request): node.secret = node_secret
return await self._relay_on_off(False, req) relay_module.switchpower(enable)
return self.ok()
async def relay_toggle(self, req: http.Request):
return await self._relay_on_off(None, req) @routes.get('/relay/{id}/on')
async def relay_on(self, req: web.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)
@routes.get('/relay/{id}/toggle')
async def relay_toggle(self, req: web.Request):
return await self._relay_on_off(None, req)
if __name__ == '__main__': if __name__ == '__main__':
@ -127,8 +133,7 @@ if __name__ == '__main__':
mqtt.connect_and_loop(loop_forever=False) mqtt.connect_and_loop(loop_forever=False)
proxy = RelayMqttHttpProxy(config.app_config['listen_addr'])
try: try:
proxy.run() http.serve(config.app_config['listen_addr'], routes=routes)
except KeyboardInterrupt: except KeyboardInterrupt:
mqtt.disconnect() mqtt.disconnect()

View File

@ -17,7 +17,7 @@ from homekit import http
def _amixer_control_response(control): def _amixer_control_response(control):
info = amixer.get(control) info = amixer.get(control)
caps = amixer.get_caps(control) caps = amixer.get_caps(control)
return http.ok({ return http.ajax_ok({
'caps': caps, 'caps': caps,
'info': info 'info': info
}) })

View File

@ -51,7 +51,7 @@ class WebAPIServer(http.HTTPServer):
@staticmethod @staticmethod
@web.middleware @web.middleware
async def validate_auth(req: http.Request, handler): async def validate_auth(req: web.Request, handler):
def get_token() -> str: def get_token() -> str:
name = 'X-Token' name = 'X-Token'
if name in req.headers: if name in req.headers:
@ -70,13 +70,13 @@ class WebAPIServer(http.HTTPServer):
return await handler(req) return await handler(req)
@staticmethod @staticmethod
async def get_index(req: http.Request): async def get_index(req: web.Request):
message = "nothing here, keep lurking" message = "nothing here, keep lurking"
if is_development_mode(): if is_development_mode():
message += ' (dev 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: try:
hours = int(req.query['hours']) hours = int(req.query['hours'])
if hours < 1 or hours > 24: if hours < 1 or hours > 24:
@ -93,7 +93,7 @@ class WebAPIServer(http.HTTPServer):
data = db.get_temperature_recordings(sensor, (dt_from, dt_to)) data = db.get_temperature_recordings(sensor, (dt_from, dt_to))
return self.ok(data) 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'])) location = SoundSensorLocation(int(req.query['location']))
after = int(req.query['after']) after = int(req.query['after'])
@ -112,7 +112,7 @@ class WebAPIServer(http.HTTPServer):
data = BotsDatabase().get_sound_hits(location, **kwargs) data = BotsDatabase().get_sound_hits(location, **kwargs)
return self.ok(data) 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 = [] hits = []
data = await req.post() data = await req.post()
for hit, count in json.loads(data['hits']): for hit, count in json.loads(data['hits']):
@ -125,7 +125,7 @@ class WebAPIServer(http.HTTPServer):
BotsDatabase().add_sound_hits(hits, datetime.now()) BotsDatabase().add_sound_hits(hits, datetime.now())
return self.ok() 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() data = await req.post()
try: try:
@ -154,7 +154,7 @@ class WebAPIServer(http.HTTPServer):
BotsDatabase().add_openwrt_logs(lines, ap) BotsDatabase().add_openwrt_logs(lines, ap)
return self.ok() 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() data = await req.post()
try: try:
@ -176,7 +176,7 @@ class WebAPIServer(http.HTTPServer):
return self.ok(files) return self.ok(files)
@staticmethod @staticmethod
def _get_inverter_from_to(req: http.Request): def _get_inverter_from_to(req: web.Request):
s_from = req.query['from'] s_from = req.query['from']
s_to = req.query['to'] s_to = req.query['to']
@ -189,12 +189,12 @@ class WebAPIServer(http.HTTPServer):
return dt_from, dt_to 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) dt_from, dt_to = self._get_inverter_from_to(req)
wh = InverterDatabase().get_consumed_energy(dt_from, dt_to) wh = InverterDatabase().get_consumed_energy(dt_from, dt_to)
return self.ok(wh) 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) dt_from, dt_to = self._get_inverter_from_to(req)
wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to) wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to)
return self.ok(wh) return self.ok(wh)

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import __py_include
import asyncio import asyncio
import logging
import jinja2 import jinja2
import aiohttp_jinja2 import aiohttp_jinja2
import json import json
@ -7,18 +9,18 @@ import re
import inverterd import inverterd
import phonenumbers import phonenumbers
import time import time
import __py_include
from io import StringIO from io import StringIO
from aiohttp.web import HTTPFound, HTTPBadRequest from aiohttp import web
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import quote_plus
from homekit.config import config, AppConfigUnit, is_development_mode, Translation from homekit.config import config, AppConfigUnit, is_development_mode, Translation
from homekit.camera import IpcamConfig 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.modem import E3372, ModemsConfig, MacroNetWorkType
from homekit.inverter.config import InverterdConfig from homekit.inverter.config import InverterdConfig
from homekit.relay.sunxi_h3_client import RelayClient from homekit.relay.sunxi_h3_client import RelayClient
from homekit import http from homekit import openwrt, http
class WebKbnConfig(AppConfigUnit): class WebKbnConfig(AppConfigUnit):
@ -36,13 +38,16 @@ class WebKbnConfig(AppConfigUnit):
} }
STATIC_FILES = [ common_static_files = [
'bootstrap.min.css', 'bootstrap.min.css',
'bootstrap.min.js', 'bootstrap.min.js',
'polyfills.js', 'polyfills.js',
'app.js', 'app.js',
'app.css' 'app.css'
] ]
routes = web.RouteTableDef()
webkbn_strings = Translation('web_kbn')
logger = logging.getLogger(__name__)
def get_js_link(file, version) -> str: def get_js_link(file, version) -> str:
@ -65,7 +70,7 @@ def get_head_static(files=None) -> str:
buf = StringIO() buf = StringIO()
if files is None: if files is None:
files = [] files = []
for file in STATIC_FILES+files: for file in common_static_files + files:
v = 2 v = 2
try: try:
q_ind = file.index('?') q_ind = file.index('?')
@ -179,245 +184,356 @@ def get_inverter_data() -> tuple:
return status, rated, html return status, rated, html
class WebSite(http.HTTPServer): def get_current_upstream() -> str:
def __init__(self, *args, **kwargs): r = openwrt.get_default_route()
super().__init__(*args, **kwargs) 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
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)
def filter_lang(key, unit='web_kbn'): def lang(key: str):
strings = Translation(unit) 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}
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
}
}
return await render(req, 'cams',
title=lang('cams'),
assets=['hls.js'],
context=dict(
mode=mode,
js_config=js_config,
))
@routes.get('/routing/main.cgi')
async def routing_main(req: web.Request):
upstream = get_current_upstream()
set_upstream_to = req.query.get('set-upstream-to', None)
mc = ModemsConfig()
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')
context = dict(
upstream=upstream,
selected_tab='main',
modems=mc.keys()
)
return await render(req, 'routing_main', title=lang('routing'), context=context)
@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:
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 strings.get()[key]
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial) env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
env.filters['lang'] = filter_lang env.filters['lang'] = filter_lang
self.app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets')) app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
self.get('/main.cgi', self.index)
self.get('/modems.cgi', self.modems)
self.get('/modems/info.ajx', self.modems_ajx)
self.get('/modems/verbose.cgi', self.modems_verbose)
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)
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}
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
}
}
return await self.render_page(req, 'cams',
title='Камеры',
assets=['hls.js'],
context=dict(
mode=mode,
js_config=js_config,
))
if __name__ == '__main__': if __name__ == '__main__':
config.load_app(WebKbnConfig) config.load_app(WebKbnConfig)
http.serve(addr=config.app_config['listen_addr'],
server = WebSite(config.app_config['listen_addr']) routes=routes,
server.run() before_start=init_web_app)

View File

@ -1,7 +0,0 @@
## Dependencies
```
apt install nginx-extras php-fpm php-mbstring php-sqlite3 php-curl php-simplexml php-gmp composer
```

View File

@ -67,7 +67,7 @@ class BaseConfigUnit(ABC):
return self._data return self._data
cur = self._data cur = self._data
pts = key.split('.') pts = str(key).split('.')
for i in range(len(pts)): for i in range(len(pts)):
k = pts[i] k = pts[i]
if i < len(pts)-1: if i < len(pts)-1:

View File

@ -1,2 +1 @@
from .http import serve, ok, routes, HTTPServer, HTTPMethod from .http import serve, ajax_ok, HTTPMethod
from aiohttp.web import FileResponse, StreamResponse, Request, Response

View File

@ -1,17 +1,46 @@
import logging import logging
import asyncio import asyncio
import html
from enum import Enum from enum import Enum
from aiohttp import web from aiohttp import web
from aiohttp.web import Response, HTTPFound from aiohttp.web import HTTPFound
from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_exceptions import HTTPNotFound
from ..util import stringify, format_tb, Addr from ..util import stringify, format_tb, Addr
_logger = logging.getLogger(__name__) _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 @web.middleware
async def errors_handler_middleware(request, handler): async def errors_handler_middleware(request, handler):
try: try:
@ -19,97 +48,56 @@ async def errors_handler_middleware(request, handler):
return response return response
except HTTPNotFound: 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: except HTTPFound as exc:
raise exc raise exc
except Exception as exc: except Exception as exc:
_logger.exception(exc) _logger.exception(exc)
data = { return _render_error(
'error': exc.__class__.__name__, error_type=exc.__class__.__name__,
'message': exc.message if hasattr(exc, 'message') else str(exc) error_message=exc.message if hasattr(exc, 'message') else str(exc),
} traceback=format_tb(exc)
tb = format_tb(exc) )
if tb:
data['stacktrace'] = tb
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 = web.Application()
app.add_routes(route_table)
app.middlewares.append(errors_handler_middleware) app.middlewares.append(errors_handler_middleware)
if routes is not None:
app.add_routes(routes)
if callable(before_start):
before_start(app)
if not event_loop:
event_loop = asyncio.get_event_loop()
runner = web.AppRunner(app, handle_signals=handle_signals)
event_loop.run_until_complete(runner.setup())
host, port = addr host, port = addr
site = web.TCPSite(runner, host=host, port=port)
event_loop.run_until_complete(site.start())
web.run_app(app, _logger.info(f'Server started at http://{host}:{port}')
host=host,
port=port, event_loop.run_forever()
handle_signals=handle_signals)
def routes() -> web.RouteTableDef: def ajax_ok(data=None):
return web.RouteTableDef()
def ok(data=None):
if data is None: if data is None:
data = 1 data = 1
response = {'response': data} response = {'response': data}
return web.json_response(response, dumps=stringify) 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): class HTTPMethod(Enum):
GET = 'GET' GET = 'GET'
POST = 'POST' POST = 'POST'

View File

@ -33,12 +33,12 @@ class MediaNodeServer(http.HTTPServer):
raise ValueError(f'invalid duration: max duration is {max}') raise ValueError(f'invalid duration: max duration is {max}')
record_id = self.recorder.record(duration) 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): async def record_info(self, request: http.Request):
record_id = int(request.match_info['id']) record_id = int(request.match_info['id'])
info = self.recorder.get_info(record_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): async def record_forget(self, request: http.Request):
record_id = int(request.match_info['id']) 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}" assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
self.recorder.forget(record_id) self.recorder.forget(record_id)
return http.ok() return http.ajax_ok()
async def record_download(self, request: http.Request): async def record_download(self, request: http.Request):
record_id = int(request.match_info['id']) record_id = int(request.match_info['id'])
@ -64,7 +64,7 @@ class MediaNodeServer(http.HTTPServer):
if extended: if extended:
files = list(map(lambda file: file.__dict__(), files)) files = list(map(lambda file: file.__dict__(), files))
return http.ok({ return http.ajax_ok({
'files': files 'files': files
}) })
@ -75,7 +75,7 @@ class MediaNodeServer(http.HTTPServer):
raise ValueError(f'file {file} not found') raise ValueError(f'file {file} not found')
self.storage.delete(file) self.storage.delete(file)
return http.ok() return http.ajax_ok()
async def storage_download(self, request): async def storage_download(self, request):
file_id = request.query['file_id'] file_id = request.query['file_id']

View File

@ -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
)

View File

@ -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}
}

View File

@ -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)

View File

@ -3,6 +3,7 @@ import json
import logging import logging
import threading import threading
from aiohttp import web
from ..database.sqlite import SQLiteBase from ..database.sqlite import SQLiteBase
from ..config import config from ..config import config
from .. import http from .. import http
@ -108,21 +109,21 @@ class SoundSensorServer:
loop.run_forever() loop.run_forever()
def run_guard_server(self): def run_guard_server(self):
routes = http.routes() routes = web.RouteTableDef()
@routes.post('/guard/enable') @routes.post('/guard/enable')
async def guard_enable(request): async def guard_enable(request):
self.set_recording(True) self.set_recording(True)
return http.ok() return http.ajax_ok()
@routes.post('/guard/disable') @routes.post('/guard/disable')
async def guard_disable(request): async def guard_disable(request):
self.set_recording(False) self.set_recording(False)
return http.ok() return http.ajax_ok()
@routes.get('/guard/status') @routes.get('/guard/status')
async def guard_status(request): 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 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

View File

@ -105,6 +105,11 @@ class Addr:
yield self.host yield self.host
yield self.port 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 # https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks
def chunks(lst, n): def chunks(lst, n):

View File

@ -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
];
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -1,11 +0,0 @@
<?php
class User extends model {
const DB_TABLE = 'users';
public int $id;
public string $username;
public string $password;
}

View File

@ -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;
}
}

View File

@ -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];
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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];
}
}

View File

@ -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];
}
}

View File

@ -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];
}

View File

@ -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;
}

View File

@ -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('/');
}
}

View File

@ -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;
}
}

View File

@ -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";
}
}
}

View File

@ -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));
}
}

View File

@ -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/');
}
}

View File

@ -1,6 +0,0 @@
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} !=/server-status
RewriteRule ^.*$ /index.php [L,QSA]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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);

View File

@ -1 +0,0 @@
Page Not Found

View File

@ -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>

View File

@ -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>

View File

@ -1,8 +0,0 @@
{% if js %}
<script>{{ js|raw }}</script>
{% endif %}
</div>
</body>
</html>
<!-- generated in {{ exec_time}} -->

View File

@ -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">

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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;
}

View File

@ -1,7 +1,7 @@
{% macro breadcrumbs(history) %} {% macro breadcrumbs(history) %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="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 %} {% for item in history %}
<li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}> <li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}>
{% if item.link %}<a href="{{ item.link }}">{% endif %} {% if item.link %}<a href="{{ item.link }}">{% endif %}

View File

@ -17,15 +17,15 @@
<h6>{{ "internet"|lang }}</h6> <h6>{{ "internet"|lang }}</h6>
<ul class="list-group list-group-flush"> <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="/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> <li class="list-group-item"><a href="/sms.cgi">{{ "sms"|lang }}</a></li>
</ul> </ul>
<h6 class="mt-4">{{ "misc"|lang }}</h6> <h6 class="mt-4">{{ "misc"|lang }}</h6>
<ul class="list-group list-group-flush"> <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="/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="/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="/sensors.cgi">{{ "sensors"|lang }}</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li>#}
</ul> </ul>
<nav class="mt-4"> <nav class="mt-4">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}