summaryrefslogtreecommitdiff
path: root/localwebsite
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-05-26 21:18:29 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-05-27 01:04:47 +0300
commitcf0b9f036b3e3eb218610e7eeececda1320d9f50 (patch)
tree39e6d1853aecb3fb77036a941a4c6df12a0ce793 /localwebsite
parentc3ed2483ea508141431be74f29f7c209271897cd (diff)
auth
Diffstat (limited to 'localwebsite')
-rw-r--r--localwebsite/classes/User.php11
-rw-r--r--localwebsite/classes/auth.php66
-rw-r--r--localwebsite/classes/config.php10
-rw-r--r--localwebsite/classes/users.php39
-rw-r--r--localwebsite/config.php8
-rw-r--r--localwebsite/engine/database.php51
-rw-r--r--localwebsite/engine/model.php243
-rw-r--r--localwebsite/functions.php24
-rw-r--r--localwebsite/handlers/AuthHandler.php36
-rw-r--r--localwebsite/handlers/MiscHandler.php15
-rw-r--r--localwebsite/handlers/RequestHandler.php11
-rw-r--r--localwebsite/htdocs/index.php4
-rw-r--r--localwebsite/templates-web/auth.twig24
-rw-r--r--localwebsite/templates-web/bc.twig12
-rw-r--r--localwebsite/templates-web/cams.twig11
-rw-r--r--localwebsite/templates-web/index.twig6
-rw-r--r--localwebsite/templates-web/inverter_page.twig11
-rw-r--r--localwebsite/templates-web/modem_status_page.twig11
-rw-r--r--localwebsite/templates-web/pump.twig11
-rw-r--r--localwebsite/templates-web/routing_header.twig11
-rw-r--r--localwebsite/templates-web/sensors.twig11
-rw-r--r--localwebsite/templates-web/sms_page.twig11
-rwxr-xr-xlocalwebsite/utils.php66
23 files changed, 649 insertions, 54 deletions
diff --git a/localwebsite/classes/User.php b/localwebsite/classes/User.php
new file mode 100644
index 0000000..9019082
--- /dev/null
+++ b/localwebsite/classes/User.php
@@ -0,0 +1,11 @@
+<?php
+
+class User extends model {
+
+ const DB_TABLE = 'users';
+
+ public int $id;
+ public string $username;
+ public string $password;
+
+}
diff --git a/localwebsite/classes/auth.php b/localwebsite/classes/auth.php
new file mode 100644
index 0000000..2cdee72
--- /dev/null
+++ b/localwebsite/classes/auth.php
@@ -0,0 +1,66 @@
+<?php
+
+class auth {
+
+ public static ?User $authorizedUser = null;
+
+ const SESSION_TIMEOUT = 86400 * 365;
+ const COOKIE_NAME = 'auth';
+
+ public static function getToken(): ?string {
+ return $_COOKIE[self::COOKIE_NAME] ?? null;
+ }
+
+ public static function setToken(string $token) {
+ setcookie(self::COOKIE_NAME,
+ $token,
+ time() + self::SESSION_TIMEOUT,
+ '/',
+ config::get('auth_cookie_host'),
+ true);
+ }
+
+ public static function resetToken() {
+ if (!headers_sent())
+ setcookie(self::COOKIE_NAME, null, -1, '/', config::get('auth_cookie_host'));
+ }
+
+ public static function id(bool $do_check = true): int {
+ if ($do_check)
+ self::check();
+
+ if (!self::$authorizedUser)
+ return 0;
+
+ return self::$authorizedUser->id;
+ }
+
+ public static function check(?string $pwhash = null): bool {
+ if (self::$authorizedUser !== null)
+ return true;
+
+ // get auth token
+ if (!$pwhash)
+ $pwhash = self::getToken();
+
+ if (!is_string($pwhash))
+ return false;
+
+ // find session by given token
+ $user = users::getUserByPwhash($pwhash);
+ if (is_null($user)) {
+ self::resetToken();
+ return false;
+ }
+
+ self::$authorizedUser = $user;
+
+ return true;
+ }
+
+ public static function logout() {
+ self::resetToken();
+ self::$authorizedUser = null;
+ }
+
+} \ No newline at end of file
diff --git a/localwebsite/classes/config.php b/localwebsite/classes/config.php
new file mode 100644
index 0000000..87ecf1c
--- /dev/null
+++ b/localwebsite/classes/config.php
@@ -0,0 +1,10 @@
+<?php
+
+class config {
+
+ public static function get(string $key) {
+ global $config;
+ return is_callable($config[$key]) ? $config[$key]() : $config[$key];
+ }
+
+} \ No newline at end of file
diff --git a/localwebsite/classes/users.php b/localwebsite/classes/users.php
new file mode 100644
index 0000000..1160dba
--- /dev/null
+++ b/localwebsite/classes/users.php
@@ -0,0 +1,39 @@
+<?php
+
+class users {
+
+ public static function add(string $username, string $password): int {
+ $db = getDB();
+ $db->insert('users', [
+ 'username' => $username,
+ 'password' => pwhash($password)
+ ]);
+ return $db->insertId();
+ }
+
+ public static function exists(string $username): bool {
+ $db = getDB();
+ $count = (int)$db->querySingle("SELECT COUNT(*) FROM users WHERE username=?", $username);
+ return $count > 0;
+ }
+
+ public static function validatePassword(string $username, string $password): bool {
+ $db = getDB();
+ $row = $db->querySingleRow("SELECT * FROM users WHERE username=?", $username);
+ if (!$row)
+ return false;
+
+ return $row['password'] == pwhash($password);
+ }
+
+ public static function getUserByPwhash(string $pwhash): ?User {
+ $db = getDB();
+ $data = $db->querySingleRow("SELECT * FROM users WHERE password=?", $pwhash);
+ return $data ? new User($data) : null;
+ }
+
+ public static function setPassword(int $id, string $new_password) {
+ getDB()->exec("UPDATE users SET password=? WHERE id=?", pwhash($new_password), $id);
+ }
+
+} \ No newline at end of file
diff --git a/localwebsite/config.php b/localwebsite/config.php
index 7ce281d..f5f8c80 100644
--- a/localwebsite/config.php
+++ b/localwebsite/config.php
@@ -57,8 +57,8 @@ return [
],
'cam_hls_access_key' => '',
- 'cam_hls_proto' => 'http',
- 'cam_hls_host' => '192.168.1.1',
+ 'cam_hls_proto' => 'http', // bool|callable
+ 'cam_hls_host' => '192.168.1.1', // bool|callable
'cam_list' => [
// fill me with names
],
@@ -70,4 +70,8 @@ return [
],
'database_path' => getenv('HOME').'/.config/homekit.localwebsite.sqlite3',
+
+ 'auth_cookie_host' => '',
+ 'auth_need' => false, // bool|callable
+ 'auth_pw_salt' => '',
];
diff --git a/localwebsite/engine/database.php b/localwebsite/engine/database.php
index 186d2ef..33f36cf 100644
--- a/localwebsite/engine/database.php
+++ b/localwebsite/engine/database.php
@@ -7,13 +7,19 @@ class database {
protected SQLite3 $link;
public function __construct(string $db_path) {
+ $will_create = !file_exists($db_path);
$this->link = new SQLite3($db_path);
+ if ($will_create)
+ setperm($db_path);
$this->link->enableExceptions(true);
$this->upgradeSchema();
}
protected function upgradeSchema() {
$cur = $this->getSchemaVersion();
+ if ($cur == self::SCHEMA_VERSION)
+ return;
+
if ($cur < 1) {
$this->link->exec("CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -77,4 +83,49 @@ class database {
return $this->link->querySingle($this->prepareQuery($sql, ...$params), true);
}
+ protected function performInsert(string $command, string $table, array $fields): SQLite3Result {
+ $names = [];
+ $values = [];
+ $count = 0;
+ foreach ($fields as $k => $v) {
+ $names[] = $k;
+ $values[] = $v;
+ $count++;
+ }
+
+ $sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")";
+ array_unshift($values, $sql);
+
+ return call_user_func_array([$this, 'query'], $values);
+ }
+
+ public function insert(string $table, array $fields): SQLite3Result {
+ return $this->performInsert('INSERT', $table, $fields);
+ }
+
+ public function replace(string $table, array $fields): SQLite3Result {
+ return $this->performInsert('REPLACE', $table, $fields);
+ }
+
+ public function insertId(): int {
+ return $this->link->lastInsertRowID();
+ }
+
+ public function update($table, $rows, ...$cond): SQLite3Result {
+ $fields = [];
+ $args = [];
+ foreach ($rows as $row_name => $row_value) {
+ $fields[] = "`{$row_name}`=?";
+ $args[] = $row_value;
+ }
+ $sql = "UPDATE `$table` SET " . implode(', ', $fields);
+ if (!empty($cond)) {
+ $sql .= " WHERE " . $cond[0];
+ if (count($cond) > 1)
+ $args = array_merge($args, array_slice($cond, 1));
+ }
+ return $this->query($sql, ...$args);
+ }
+
+
} \ No newline at end of file
diff --git a/localwebsite/engine/model.php b/localwebsite/engine/model.php
new file mode 100644
index 0000000..4dd981c
--- /dev/null
+++ b/localwebsite/engine/model.php
@@ -0,0 +1,243 @@
+<?php
+
+abstract class model {
+
+ const DB_TABLE = null;
+ const DB_KEY = 'id';
+
+ const STRING = 0;
+ const INTEGER = 1;
+ const FLOAT = 2;
+ const ARRAY = 3;
+ const BOOLEAN = 4;
+ const JSON = 5;
+ const SERIALIZED = 6;
+
+ protected static array $SpecCache = [];
+
+ public static function create_instance(...$args) {
+ $cl = get_called_class();
+ return new $cl(...$args);
+ }
+
+ public function __construct(array $raw) {
+ if (!isset(self::$SpecCache[static::class])) {
+ list($fields, $model_name_map, $db_name_map) = static::get_spec();
+ self::$SpecCache[static::class] = [
+ 'fields' => $fields,
+ 'model_name_map' => $model_name_map,
+ 'db_name_map' => $db_name_map
+ ];
+ }
+
+ foreach (self::$SpecCache[static::class]['fields'] as $field)
+ $this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]);
+
+ if (is_null(static::DB_TABLE))
+ trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined');
+ }
+
+ /**
+ * @param $fields
+ *
+ * TODO: support adding or subtracting (SET value=value+1)
+ */
+ public function edit($fields) {
+ $db = getDB();
+
+ $model_upd = [];
+ $db_upd = [];
+
+ foreach ($fields as $name => $value) {
+ $index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null;
+ if (is_null($index)) {
+ debugError(__METHOD__.': field `'.$name.'` not found in '.static::class);
+ continue;
+ }
+
+ $field = self::$SpecCache[static::class]['fields'][$index];
+ switch ($field['type']) {
+ case self::ARRAY:
+ if (is_array($value)) {
+ $db_upd[$name] = implode(',', $value);
+ $model_upd[$field['model_name']] = $value;
+ } else {
+ debugError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.');
+ }
+ break;
+
+ case self::INTEGER:
+ $value = (int)$value;
+ $db_upd[$name] = $value;
+ $model_upd[$field['model_name']] = $value;
+ break;
+
+ case self::FLOAT:
+ $value = (float)$value;
+ $db_upd[$name] = $value;
+ $model_upd[$field['model_name']] = $value;
+ break;
+
+ case self::BOOLEAN:
+ $db_upd[$name] = $value ? 1 : 0;
+ $model_upd[$field['model_name']] = $value;
+ break;
+
+ case self::JSON:
+ $db_upd[$name] = jsonEncode($value);
+ $model_upd[$field['model_name']] = $value;
+ break;
+
+ case self::SERIALIZED:
+ $db_upd[$name] = serialize($value);
+ $model_upd[$field['model_name']] = $value;
+ break;
+
+ default:
+ $value = (string)$value;
+ $db_upd[$name] = $value;
+ $model_upd[$field['model_name']] = $value;
+ break;
+ }
+ }
+
+ if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) {
+ debugError(__METHOD__.': failed to update database');
+ return;
+ }
+
+ if (!empty($model_upd)) {
+ foreach ($model_upd as $name => $value)
+ $this->{$name} = $value;
+ }
+ }
+
+ public function get_id() {
+ return $this->{to_camel_case(static::DB_KEY)};
+ }
+
+ public function as_array(array $fields = [], array $custom_getters = []): array {
+ if (empty($fields))
+ $fields = array_keys(static::$SpecCache[static::class]['db_name_map']);
+
+ $array = [];
+ foreach ($fields as $field) {
+ if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
+ $array[$field] = $custom_getters[$field]();
+ } else {
+ $array[$field] = $this->{to_camel_case($field)};
+ }
+ }
+
+ return $array;
+ }
+
+ protected static function cast_to_type(int $type, $value) {
+ switch ($type) {
+ case self::BOOLEAN:
+ return (bool)$value;
+
+ case self::INTEGER:
+ return (int)$value;
+
+ case self::FLOAT:
+ return (float)$value;
+
+ case self::ARRAY:
+ return array_filter(explode(',', $value));
+
+ case self::JSON:
+ $val = jsonDecode($value);
+ if (!$val)
+ $val = null;
+ return $val;
+
+ case self::SERIALIZED:
+ $val = unserialize($value);
+ if ($val === false)
+ $val = null;
+ return $val;
+
+ default:
+ return (string)$value;
+ }
+ }
+
+ protected static function get_spec(): array {
+ $rc = new ReflectionClass(static::class);
+ $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
+
+ $list = [];
+ $index = 0;
+
+ $model_name_map = [];
+ $db_name_map = [];
+
+ foreach ($props as $prop) {
+ if ($prop->isStatic())
+ continue;
+
+ $name = $prop->getName();
+ if (startsWith($name, '_'))
+ continue;
+
+ $type = $prop->getType();
+ $phpdoc = $prop->getDocComment();
+
+ $mytype = null;
+ if (!$prop->hasType() && !$phpdoc)
+ $mytype = self::STRING;
+ else {
+ $typename = $type->getName();
+ switch ($typename) {
+ case 'string':
+ $mytype = self::STRING;
+ break;
+ case 'int':
+ $mytype = self::INTEGER;
+ break;
+ case 'float':
+ $mytype = self::FLOAT;
+ break;
+ case 'array':
+ $mytype = self::ARRAY;
+ break;
+ case 'bool':
+ $mytype = self::BOOLEAN;
+ break;
+ }
+
+ if ($phpdoc != '') {
+ $pos = strpos($phpdoc, '@');
+ if ($pos === false)
+ continue;
+
+ if (substr($phpdoc, $pos+1, 4) == 'json')
+ $mytype = self::JSON;
+ else if (substr($phpdoc, $pos+1, 5) == 'array')
+ $mytype = self::ARRAY;
+ else if (substr($phpdoc, $pos+1, 10) == 'serialized')
+ $mytype = self::SERIALIZED;
+ }
+ }
+
+ if (is_null($mytype))
+ debugError(__METHOD__.": ".$name." is still null in ".static::class);
+
+ $dbname = from_camel_case($name);
+ $list[] = [
+ 'type' => $mytype,
+ 'model_name' => $name,
+ 'db_name' => $dbname
+ ];
+
+ $model_name_map[$name] = $index;
+ $db_name_map[$dbname] = $index;
+
+ $index++;
+ }
+
+ return [$list, $model_name_map, $db_name_map];
+ }
+
+}
diff --git a/localwebsite/functions.php b/localwebsite/functions.php
index f46a534..4757765 100644
--- a/localwebsite/functions.php
+++ b/localwebsite/functions.php
@@ -242,6 +242,10 @@ function bytesToUnitsLabel(GMP $b): string {
return gmp_strval($b);
}
+function pwhash(string $s): string {
+ return hash('sha256', config::get('auth_pw_salt').'|'.$s);
+}
+
$ShutdownFunctions = [];
function append_shutdown_function(callable $f) {
@@ -255,11 +259,27 @@ function prepend_shutdown_function(callable $f) {
}
function getDB(): database {
- global $config;
static $link = null;
if (is_null($link))
- $link = new database($config['database_path']);
+ $link = new database(config::get('database_path'));
return $link;
+}
+
+function to_camel_case(string $input, string $separator = '_'): string {
+ return lcfirst(str_replace($separator, '', ucwords($input, $separator)));
+}
+
+function from_camel_case(string $s): string {
+ $buf = '';
+ $len = strlen($s);
+ for ($i = 0; $i < $len; $i++) {
+ if (!ctype_upper($s[$i])) {
+ $buf .= $s[$i];
+ } else {
+ $buf .= '_'.strtolower($s[$i]);
+ }
+ }
+ return $buf;
} \ No newline at end of file
diff --git a/localwebsite/handlers/AuthHandler.php b/localwebsite/handlers/AuthHandler.php
new file mode 100644
index 0000000..971f850
--- /dev/null
+++ b/localwebsite/handlers/AuthHandler.php
@@ -0,0 +1,36 @@
+<?php
+
+class AuthHandler extends RequestHandler {
+
+ protected function before_dispatch(string $method, string $act) {
+ return null;
+ }
+
+ public function GET_auth() {
+ list($error) = $this->input('error');
+ $this->tpl->set(['error' => $error]);
+ $this->tpl->set_title('Авторизация');
+ $this->tpl->render_page('auth.twig');
+ }
+
+ public function POST_auth() {
+ list($username, $password) = $this->input('username, password');
+
+ $result = users::validatePassword($username, $password);
+ if (!$result) {
+ debugError('invalid login attempt: '.$_SERVER['REMOTE_ADDR'].', '.$_SERVER['HTTP_USER_AGENT'].", username=$username, password=$password");
+ redirect('/auth/?error='.urlencode('неверный логин или пароль'));
+ }
+
+ auth::setToken(pwhash($password));
+ redirect('/');
+ }
+
+ public function GET_deauth() {
+ if (auth::id())
+ auth::logout();
+
+ redirect('/');
+ }
+
+}
diff --git a/localwebsite/handlers/MiscHandler.php b/localwebsite/handlers/MiscHandler.php
index ef4d8ef..b7c312a 100644
--- a/localwebsite/handlers/MiscHandler.php
+++ b/localwebsite/handlers/MiscHandler.php
@@ -8,11 +8,6 @@ class MiscHandler extends RequestHandler
$this->tpl->render_page('index.twig');
}
- public function GET_phpinfo() {
- phpinfo();
- exit;
- }
-
public function GET_sensors_page() {
global $config;
@@ -68,9 +63,9 @@ class MiscHandler extends RequestHandler
$hls_opts['debug'] = true;
$this->tpl->add_external_static('js', 'https://cdn.jsdelivr.net/npm/hls.js@latest');
-
- $hls_host = is_callable($config['cam_hls_host']) ? $config['cam_hls_host']() : $config['cam_hls_host'];
- $hls_proto = is_callable($config['cam_hls_proto']) ? $config['cam_hls_proto']() : $config['cam_hls_proto'];
+
+ $hls_host = config::get('cam_hls_host');
+ $hls_proto = config::get('cam_hls_proto');
$this->tpl->set([
'hls_host' => $hls_host,
@@ -89,4 +84,8 @@ class MiscHandler extends RequestHandler
print_r($_SERVER);
}
+ public function GET_phpinfo() {
+ phpinfo();
+ }
+
} \ No newline at end of file
diff --git a/localwebsite/handlers/RequestHandler.php b/localwebsite/handlers/RequestHandler.php
index 2fffdc0..136a23e 100644
--- a/localwebsite/handlers/RequestHandler.php
+++ b/localwebsite/handlers/RequestHandler.php
@@ -15,6 +15,12 @@ class RequestHandler extends request_handler {
$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) {
@@ -38,4 +44,9 @@ class RequestHandler extends request_handler {
ajax_error('unknown act "'.$act.'"', 404);
}
+
+ protected function before_dispatch(string $method, string $act) {
+ if (config::get('auth_need') && !auth::id())
+ redirect('/auth/');
+ }
} \ No newline at end of file
diff --git a/localwebsite/htdocs/index.php b/localwebsite/htdocs/index.php
index 9ce8324..8c9d092 100644
--- a/localwebsite/htdocs/index.php
+++ b/localwebsite/htdocs/index.php
@@ -28,6 +28,10 @@ $router->add('phpinfo/', 'Misc phpinfo');
$router->add('cams/', 'Misc cams');
$router->add('debug/', 'Misc debug');
+// auth
+$router->add('auth/', 'Auth auth');
+$router->add('deauth/', 'Auth deauth');
+
$route = routerFind($router);
if ($route === false)
diff --git a/localwebsite/templates-web/auth.twig b/localwebsite/templates-web/auth.twig
new file mode 100644
index 0000000..a0107b3
--- /dev/null
+++ b/localwebsite/templates-web/auth.twig
@@ -0,0 +1,24 @@
+{% include 'bc.twig' with {
+ history: [
+ {text: "Авторизация" }
+ ]
+} %}
+
+{% if error %}
+ <div class="mt-4 alert alert-danger"><b>Ошибка:</b> {{ error }}</div>
+{% endif %}
+
+
+<form method="post" action="/auth/">
+ <div class="mt-2">
+ <input type="text" name="username" placeholder="Логин" class="form-control">
+ </div>
+
+ <div class="mt-2">
+ <input type="password" name="password" placeholder="Пароль" class="form-control">
+ </div>
+
+ <div class="mt-2">
+ <button type="submit" class="btn btn-outline-primary">Войти</button>
+ </div>
+</form> \ No newline at end of file
diff --git a/localwebsite/templates-web/bc.twig b/localwebsite/templates-web/bc.twig
new file mode 100644
index 0000000..b74ad40
--- /dev/null
+++ b/localwebsite/templates-web/bc.twig
@@ -0,0 +1,12 @@
+<nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item"><a href="/">Главная</a></li>
+ {% for item in history %}
+ <li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}>
+ {% if item.link %}<a href="{{ item.link }}">{% endif %}
+ {{ item.html ? item.html|raw : item.text }}
+ {% if item.link %}</a>{% endif %}
+ </li>
+ {% endfor %}
+ </ol>
+</nav> \ No newline at end of file
diff --git a/localwebsite/templates-web/cams.twig b/localwebsite/templates-web/cams.twig
index eb9e6d0..4fc815d 100644
--- a/localwebsite/templates-web/cams.twig
+++ b/localwebsite/templates-web/cams.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Камеры</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "Камеры" }
+ ]
+} %}
<div id="videos" class="camfeeds"></div>
diff --git a/localwebsite/templates-web/index.twig b/localwebsite/templates-web/index.twig
index 1635459..8a0bdaf 100644
--- a/localwebsite/templates-web/index.twig
+++ b/localwebsite/templates-web/index.twig
@@ -5,6 +5,12 @@
</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/status/">Состояние</a></li>
diff --git a/localwebsite/templates-web/inverter_page.twig b/localwebsite/templates-web/inverter_page.twig
index 2b0af90..2c3f8dd 100644
--- a/localwebsite/templates-web/inverter_page.twig
+++ b/localwebsite/templates-web/inverter_page.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Инвертор</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "Инвертор" }
+ ]
+} %}
<h6 class="text-primary">Статус</h6>
<div id="inverter_status">
diff --git a/localwebsite/templates-web/modem_status_page.twig b/localwebsite/templates-web/modem_status_page.twig
index 1aa5cf8..f2b999b 100644
--- a/localwebsite/templates-web/modem_status_page.twig
+++ b/localwebsite/templates-web/modem_status_page.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Модемы</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "Модемы" }
+ ]
+} %}
{% for modem_key, modem in modems %}
<h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ modem.label }}</h6>
diff --git a/localwebsite/templates-web/pump.twig b/localwebsite/templates-web/pump.twig
index 4a8cad5..3bce0e2 100644
--- a/localwebsite/templates-web/pump.twig
+++ b/localwebsite/templates-web/pump.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Насос</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "Насос" }
+ ]
+} %}
<form action="/pump/" method="get">
<input type="hidden" name="set" value="{{ status == 'on' ? 'off' : 'on' }}" />
diff --git a/localwebsite/templates-web/routing_header.twig b/localwebsite/templates-web/routing_header.twig
index f7322f9..8cb5f47 100644
--- a/localwebsite/templates-web/routing_header.twig
+++ b/localwebsite/templates-web/routing_header.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Маршрутизация</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "Маршрутизация" }
+ ]
+} %}
{% set routing_tabs = [
{tab: 'smallhome', url: '/routing/', label: 'Маленький дом'},
diff --git a/localwebsite/templates-web/sensors.twig b/localwebsite/templates-web/sensors.twig
index 354e4e7..14f8454 100644
--- a/localwebsite/templates-web/sensors.twig
+++ b/localwebsite/templates-web/sensors.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">Датчики</li>
- </ol>
-</nav>
+{% 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>
diff --git a/localwebsite/templates-web/sms_page.twig b/localwebsite/templates-web/sms_page.twig
index b6551a3..f60d223 100644
--- a/localwebsite/templates-web/sms_page.twig
+++ b/localwebsite/templates-web/sms_page.twig
@@ -1,9 +1,8 @@
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item"><a href="/">Главная</a></li>
- <li class="breadcrumb-item active" aria-current="page">SMS-сообщения</li>
- </ol>
-</nav>
+{% include 'bc.twig' with {
+ history: [
+ {text: "SMS-сообщения" }
+ ]
+} %}
<nav>
<div class="nav nav-tabs" id="nav-tab">
diff --git a/localwebsite/utils.php b/localwebsite/utils.php
new file mode 100755
index 0000000..333ebfc
--- /dev/null
+++ b/localwebsite/utils.php
@@ -0,0 +1,66 @@
+#!/usr/bin/env php
+<?php
+
+require_once __DIR__.'/init.php';
+
+function read_stdin(?string $prompt = null, bool $multiline = true) {
+ if (!is_null($prompt))
+ echo $prompt;
+
+ if (!$multiline)
+ return trim(fgets(STDIN));
+
+ $fp = fopen('php://stdin', 'r');
+ $data = stream_get_contents($fp);
+ fclose($fp);
+
+ return $data;
+}
+
+function usage() {
+ global $argv;
+ echo <<<EOF
+usage: {$argv[0]} COMMAND
+
+Supported commands:
+ add-user
+ change-password
+
+EOF;
+ exit(1);
+}
+
+if (empty($argv[1]))
+ usage();
+
+switch ($argv[1]) {
+ case 'add-user':
+ $username = read_stdin('enter username: ', false);
+ $password = read_stdin('enter password: ', false);
+
+ if (users::exists($username)) {
+ fwrite(STDERR, "user already exists\n");
+ exit(1);
+ }
+
+ $id = users::add($username, $password);
+ echo "added user, id = $id\n";
+
+ break;
+
+ case 'change-password':
+ $id = (int)read_stdin('enter ID: ', false);
+ if (!$id)
+ die("invalid id\n");
+
+ $password = read_stdin('enter new password: ', false);
+ if (!$password)
+ die("invalid password\n");
+
+ users::setPassword($id, $password);
+ break;
+
+ default:
+ fwrite(STDERR, "invalid command\n");
+ exit(1);
+} \ No newline at end of file