Files
inventorEZ/api.php
T

424 lines
23 KiB
PHP
Raw Permalink Normal View History

2026-10-24 16:53:46 +03:00
<?php
declare(strict_types=1);
const D = __DIR__ . '/data/';
const LIST_TYPES = ['Коробка', 'Мешок', 'Полка'];
const COL_TYPES = ['text', 'number', 'date', 'select', 'check'];
const STD_IDS = ['name', 'qty', 'cat', 'loc'];
const ONLINE_SEC = 120;
if (!function_exists('mb_substr')) { function mb_substr($s, $a, $l = null) { preg_match_all('/./us', (string)$s, $m); return implode('', array_slice($m[0], $a, $l)); } }
if (!function_exists('mb_strlen')) { function mb_strlen($s) { return (int)preg_match_all('/./us', (string)$s); } }
if (!function_exists('mb_strtolower')) {
function mb_strtolower($s) {
static $m = null;
if ($m === null) $m = array_combine(preg_split('//u', 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ', -1, PREG_SPLIT_NO_EMPTY), preg_split('//u', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя', -1, PREG_SPLIT_NO_EMPTY));
return strtr(strtolower((string)$s), $m);
}
}
class ApiError extends Exception {}
function fail(string $m, int $c = 400): void { throw new ApiError($m, $c); }
ini_set('display_errors', '0');
set_error_handler(function ($no, $msg, $file, $line) { throw new ErrorException($msg, 0, $no, $file, $line); });
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
set_exception_handler(function ($e) {
if (!($e instanceof ApiError)) error_log('inventory api: ' . $e);
http_response_code($e instanceof ApiError ? ($e->getCode() ?: 400) : 500);
echo json_encode(['error' => $e instanceof ApiError ? $e->getMessage() : 'Ошибка сервера (подробности в логе PHP)'], JSON_UNESCAPED_UNICODE);
});
function out($d, int $c = 200): void { http_response_code($c); echo json_encode($d, JSON_UNESCAPED_UNICODE); exit; }
function rd(string $f, $def) { $p = D . $f; if (!is_file($p)) return $def; $j = json_decode((string)file_get_contents($p), true); return $j === null ? $def : $j; }
function wr(string $f, $d): void {
$p = D . $f; $t = $p . '.tmp';
file_put_contents($t, json_encode($d, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
rename($t, $p);
}
function str($v, int $max): string { return is_scalar($v) ? mb_substr(trim((string)$v), 0, $max) : ''; }
function new_id(string $p = ''): string { return $p . bin2hex(random_bytes(5)); }
function load_ref(): array {
$r = rd('ref.json', []);
if (!isset($r['dicts'])) {
$r = ['dicts' => [
['id' => 'cats', 'name' => 'Категории', 'values' => array_values($r['cats'] ?? ['Категория 1', 'Категория 2', 'Категория 3'])],
['id' => 'locs', 'name' => 'Расположения', 'values' => array_values($r['locs'] ?? ['Склад', 'Дом', 'Воронеж'])],
], 'columns' => []];
}
$r['columns'] = $r['columns'] ?? [];
if (dict_idx($r, 'ltypes') < 0) $r['dicts'][] = ['id' => 'ltypes', 'name' => 'Типы списков', 'values' => LIST_TYPES];
return $r;
}
function std_cols(): array {
return [
['id' => 'name', 'name' => 'Название', 'type' => 'text', 'dict' => null],
['id' => 'qty', 'name' => 'Кол-во', 'type' => 'number', 'dict' => null],
['id' => 'cat', 'name' => 'Категория', 'type' => 'select', 'dict' => 'cats'],
['id' => 'loc', 'name' => 'Расположение', 'type' => 'select', 'dict' => 'locs'],
];
}
function cols_map(array $ref): array {
$m = [];
foreach (array_merge(std_cols(), $ref['columns']) as $c) $m[$c['id']] = $c;
return $m;
}
function ltypes(array $ref): array { $i = dict_idx($ref, 'ltypes'); return $i < 0 ? LIST_TYPES : $ref['dicts'][$i]['values']; }
function ref_values(array $ref, string $id): array { $i = dict_idx($ref, $id); return $i < 0 ? [] : $ref['dicts'][$i]['values']; }
function dict_idx(array $ref, string $id): int {
foreach ($ref['dicts'] as $i => $d) if ($d['id'] === $id) return $i;
return -1;
}
function norm_list(array $l): array {
if (!isset($l['kind'])) {
$l['kind'] = 'standard';
$l['cols'] = STD_IDS;
$l['ltype'] = (($l['type'] ?? '') === 'box') ? 'Коробка' : '';
$items = [];
foreach ($l['items'] ?? [] as $it) {
$items[] = ['id' => (string)($it['id'] ?? new_id()), 'v' => [
'name' => (string)($it['name'] ?? ''), 'qty' => (string)($it['qty'] ?? ''),
'cat' => (string)($it['cat'] ?? ''), 'loc' => (string)($it['loc'] ?? '')]];
}
$l['items'] = $items;
unset($l['type']);
}
$l['ltype'] = $l['ltype'] ?? '';
return $l;
}
function load_lists(): array { return array_map('norm_list', rd('lists.json', [])); }
function list_idx(array $lists, string $id): int {
foreach ($lists as $i => $l) if ($l['id'] === $id) return $i;
return -1;
}
function same_cols(array $a, array $b): bool {
$a = $a['cols']; $b = $b['cols']; sort($a); sort($b);
return $a === $b;
}
function map_items(array $items, array $dst, array $ref): array {
$out = [];
foreach ($items as $it) {
$v = [];
foreach ($dst['cols'] as $c) $v[$c] = (string)($it['v'][$c] ?? '');
if ($dst['kind'] === 'standard') {
if ($v['name'] === '') $v['name'] = 'Без названия';
if ($v['cat'] === '') $v['cat'] = (string)(ref_values($ref, 'cats')[0] ?? '');
if ($v['loc'] === '') $v['loc'] = (string)(ref_values($ref, 'locs')[0] ?? '');
}
$out[] = ['id' => new_id(), 'v' => $v];
}
return $out;
}
function clean_val(array $col, $v, array $ref): string {
$v = is_scalar($v) ? trim((string)$v) : '';
switch ($col['type']) {
case 'text':
return mb_substr($v, 0, 200);
case 'number':
if ($v === '') return '';
if (!is_numeric($v) || strlen($v) > 20) fail('«' . $col['name'] . '»: нужно число');
return $v;
case 'date':
if ($v === '') return '';
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m) || !checkdate((int)$m[2], (int)$m[3], (int)$m[1])) fail('«' . $col['name'] . '»: неверная дата');
return $v;
case 'check':
return ($v === '1' || $v === 'true') ? '1' : '';
case 'select':
if ($v === '') return '';
$di = dict_idx($ref, (string)$col['dict']);
if ($di < 0 || !in_array($v, $ref['dicts'][$di]['values'], true)) fail('«' . $col['name'] . '»: значения «' . mb_substr($v, 0, 40) . '» нет в справочнике');
return $v;
}
return '';
}
function clean_row($raw, array $cols, array $cm, array $ref, string $kind): array {
$raw = is_array($raw) ? $raw : [];
$v = []; $any = false;
foreach ($cols as $c) {
$v[$c] = clean_val($cm[$c], $raw[$c] ?? '', $ref);
if ($v[$c] !== '') $any = true;
}
if ($kind === 'standard') {
if ($v['name'] === '') fail('У предмета должно быть название');
if ($v['cat'] === '' || $v['loc'] === '') fail('Укажите категорию и расположение');
} elseif (!$any) fail('Строка пустая');
return $v;
}
function clean_list(array $l, array $ref, ?array $old): array {
$id = (string)($l['id'] ?? '');
if (!preg_match('/^[a-z0-9]{3,20}$/', $id)) fail('Некорректный список');
$cm = cols_map($ref);
$lt = str($l['ltype'] ?? '', 60);
$oldLt = $old ? (string)($old['ltype'] ?? '') : '';
$ltype = ($lt === '' || in_array($lt, ltypes($ref), true) || $lt === $oldLt) ? $lt : $oldLt;
if ($old) {
$kind = $old['kind']; $cols = $old['cols'];
} else {
$kind = (($l['kind'] ?? '') === 'custom') ? 'custom' : 'standard';
if ($kind === 'standard') $cols = STD_IDS;
else {
$cols = array_values(array_unique(array_map('strval', (array)($l['cols'] ?? []))));
if (!$cols || count($cols) > 12) fail('Выберите от 1 до 12 колонок');
foreach ($cols as $c) if (!isset($cm[$c])) fail('Неизвестная колонка');
}
}
$src = $l['items'] ?? null;
if (!is_array($src) || count($src) > 5000) fail('Некорректный список предметов');
$items = [];
foreach ($src as $it) {
$iid = substr(preg_replace('/[^a-z0-9]/', '', strtolower((string)($it['id'] ?? ''))), 0, 20);
$items[] = ['id' => $iid !== '' ? $iid : new_id(), 'v' => clean_row($it['v'] ?? [], $cols, $cm, $ref, $kind)];
}
return ['id' => $id, 'name' => str($l['name'] ?? '', 100) ?: 'Список', 'kind' => $kind, 'ltype' => $ltype,
'cols' => $cols, 'final' => !empty($l['final']), 'items' => $items];
}
function touch_presence(string $u): void {
$pr = rd('presence.json', []); $now = time();
if (($pr[$u] ?? 0) > $now - 15) return;
$pr[$u] = $now;
foreach ($pr as $k => $t) if ($t < $now - 86400) unset($pr[$k]);
wr('presence.json', $pr);
}
function session_info(): array {
return ['user' => ['login' => $_SESSION['u'], 'role' => $_SESSION['role']], 'csrf' => $_SESSION['csrf'], 'mustChange' => !empty($_SESSION['must'])];
}
session_name('inv');
session_set_cookie_params(['httponly' => true, 'samesite' => 'Strict', 'secure' => !empty($_SERVER['HTTPS'])]);
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') out(['error' => 'POST only'], 405);
$in = json_decode((string)file_get_contents('php://input'), true) ?: [];
$a = (string)($in['a'] ?? '');
$lock = fopen(D . '.lock', 'c'); flock($lock, LOCK_EX);
if ($a === 'login') {
$login = mb_strtolower(str($in['login'] ?? '', 50));
$ip = $_SERVER['REMOTE_ADDR'] ?? '?';
$buckets = [[hash('sha256', "$ip|$login"), 5], [hash('sha256', $ip), 20]];
$at = rd('attempts.json', []); $now = time();
foreach ($at as $k => $v) if (($v['t'] ?? 0) < $now - 3600 && ($v['until'] ?? 0) < $now) unset($at[$k]);
foreach ($buckets as [$k]) if (($at[$k]['until'] ?? 0) > $now) { wr('attempts.json', $at); out(['error' => 'Слишком много попыток. Подождите несколько минут.'], 429); }
$users = rd('users.json', ['users' => []]); $u = null; $ui = -1;
foreach ($users['users'] as $i => $x) if ($x['login'] === $login) { $u = $x; $ui = $i; }
$pw = (string)($in['password'] ?? '');
$ok = password_verify($pw, $u['hash'] ?? password_hash('dummy', PASSWORD_DEFAULT)) && $u !== null;
if (!$ok) {
foreach ($buckets as [$k, $lim]) {
$n = ($at[$k]['n'] ?? 0) + 1;
$at[$k] = ['n' => $n, 't' => $now, 'until' => $n >= $lim ? $now + 600 : 0];
if ($n >= $lim) $at[$k]['n'] = 0;
}
wr('attempts.json', $at); usleep(500000);
out(['error' => 'Неверный логин или пароль'], 401);
}
foreach ($buckets as [$k]) unset($at[$k]);
wr('attempts.json', $at);
if (password_needs_rehash($u['hash'], PASSWORD_DEFAULT)) { $users['users'][$ui]['hash'] = password_hash($pw, PASSWORD_DEFAULT); wr('users.json', $users); }
session_regenerate_id(true);
$_SESSION = ['u' => $u['login'], 'role' => $u['role'], 'csrf' => bin2hex(random_bytes(16)), 'must' => !empty($u['must_change'])];
touch_presence($u['login']);
out(session_info());
}
if (empty($_SESSION['u'])) out(['error' => 'auth'], 401);
touch_presence((string)$_SESSION['u']);
if ($a === 'me') out(session_info());
if (!hash_equals($_SESSION['csrf'], (string)($_SERVER['HTTP_X_CSRF'] ?? ''))) out(['error' => 'CSRF'], 403);
$admin = $_SESSION['role'] === 'admin';
if (!empty($_SESSION['must']) && !in_array($a, ['logout', 'passwd'], true)) fail('Сначала смените пароль', 403);
$ref = load_ref();
switch ($a) {
case 'ping':
out(['ok' => true]);
case 'logout':
$pr = rd('presence.json', []); unset($pr[$_SESSION['u']]); wr('presence.json', $pr);
$_SESSION = []; session_destroy(); out(['ok' => true]);
case 'passwd':
$users = rd('users.json', ['users' => []]);
$new = (string)($in['new'] ?? '');
$oldPw = (string)($in['old'] ?? '');
if (mb_strlen($new) < 8) fail('Новый пароль — не короче 8 символов');
if ($new === $oldPw) fail('Новый пароль должен отличаться от текущего');
foreach ($users['users'] as $i => $x) if ($x['login'] === $_SESSION['u']) {
if (!password_verify($oldPw, $x['hash'])) fail('Неверный текущий пароль', 403);
$users['users'][$i]['hash'] = password_hash($new, PASSWORD_DEFAULT);
unset($users['users'][$i]['must_change']);
wr('users.json', $users); $_SESSION['must'] = false; out(['ok' => true]);
}
fail('Пользователь не найден', 404);
case 'state':
$r = ['ref' => $ref, 'std' => std_cols(), 'listTypes' => ltypes($ref)];
if (!$admin) $r['lists'] = load_lists();
out($r);
case 'users':
if (!$admin) fail('forbidden', 403);
$pr = rd('presence.json', []); $now = time(); $res = [];
foreach (rd('users.json', ['users' => []])['users'] as $x)
$res[] = ['login' => $x['login'], 'role' => $x['role'] ?? 'user', 'online' => ($pr[$x['login']] ?? 0) > $now - ONLINE_SEC];
out(['users' => $res]);
case 'user_add':
if (!$admin) fail('forbidden', 403);
$nl = mb_strtolower(str($in['login'] ?? '', 30)); $np = (string)($in['password'] ?? '');
if (!preg_match('/^[a-z0-9_.-]{2,30}$/', $nl)) fail('Логин: 2–30 символов, латинские буквы, цифры и знаки _ . -');
if (mb_strlen($np) < 8) fail('Пароль — не короче 8 символов');
$users = rd('users.json', ['users' => []]);
foreach ($users['users'] as $x) if ($x['login'] === $nl) fail('Такой логин уже есть');
if (count($users['users']) >= 50) fail('Слишком много пользователей');
$users['users'][] = ['login' => $nl, 'role' => 'user', 'hash' => password_hash($np, PASSWORD_DEFAULT)];
wr('users.json', $users); out(['ok' => true]);
case 'list_save':
if ($admin) fail('forbidden', 403);
$lists = load_lists();
$id = (string)($in['list']['id'] ?? '');
$i = list_idx($lists, $id);
$new = clean_list((array)($in['list'] ?? []), $ref, $i >= 0 ? $lists[$i] : null);
if ($i >= 0) $lists[$i] = $new; else array_unshift($lists, $new);
wr('lists.json', $lists); out(['ok' => true, 'list' => $new]);
case 'list_delete':
if ($admin) fail('forbidden', 403);
wr('lists.json', array_values(array_filter(load_lists(), fn($x) => $x['id'] !== ($in['id'] ?? ''))));
out(['ok' => true]);
case 'transfer':
if ($admin) fail('forbidden', 403);
$lists = load_lists();
$si = list_idx($lists, (string)($in['from'] ?? ''));
if ($si < 0) fail('Исходный список не найден', 404);
$ids = array_map('strval', (array)($in['ids'] ?? []));
$picked = array_values(array_filter($lists[$si]['items'], fn($it) => in_array($it['id'], $ids, true)));
if (!$picked) fail('Не выбрано ни одного предмета');
$copy = fn() => array_map(fn($it) => ['id' => new_id(), 'v' => $it['v']], $picked);
$move = !empty($in['move']);
if ($move && !empty($lists[$si]['final'])) fail('Исходный список закрыт итогом — перенос с удалением невозможен');
$to = (array)($in['to'] ?? []);
if (isset($to['existing'])) {
$ti = list_idx($lists, (string)$to['existing']);
if ($ti < 0 || $ti === $si) fail('Список назначения не найден', 404);
if (!empty($lists[$ti]['final'])) fail('Список назначения закрыт итогом');
if (!in_array('name', $lists[$si]['cols'], true) || !in_array('name', $lists[$ti]['cols'], true)) fail('В обоих списках должна быть колонка «Название»');
if (!same_cols($lists[$si], $lists[$ti]) && empty($in['confirm'])) fail('Колонки списков отличаются, нужно подтверждение');
if (count($lists[$ti]['items']) + count($picked) > 5000) fail('Слишком много предметов в списке');
$lists[$ti]['items'] = array_merge($lists[$ti]['items'], map_items($picked, $lists[$ti], $ref));
$target = $lists[$ti]['id'];
} else {
$name = str($to['name'] ?? '', 100);
$lt = (string)($to['ltype'] ?? '');
if ($name === '') fail('Укажите название списка');
if (!in_array($lt, ltypes($ref), true)) fail('Выберите тип списка');
$nl = ['id' => new_id('l'), 'name' => $name, 'kind' => $lists[$si]['kind'], 'ltype' => $lt,
'cols' => $lists[$si]['cols'], 'final' => false, 'items' => $copy()];
array_unshift($lists, $nl); $si++;
$target = $nl['id'];
}
if ($move) {
$lists[$si]['items'] = array_values(array_filter($lists[$si]['items'], fn($it) => !in_array($it['id'], $ids, true)));
}
wr('lists.json', $lists);
out(['ok' => true, 'target' => $target, 'lists' => $lists]);
case 'dict_op':
if (!$admin) fail('forbidden', 403);
$op = (string)($in['op'] ?? ''); $did = (string)($in['id'] ?? '');
$v = str($in['value'] ?? '', 60); $old = (string)($in['old'] ?? '');
if ($op === 'add_dict') {
if ($v === '') fail('Введите название справочника');
foreach ($ref['dicts'] as $d) if (mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть');
if (count($ref['dicts']) >= 50) fail('Слишком много справочников');
$ref['dicts'][] = ['id' => new_id('d'), 'name' => $v, 'values' => []];
wr('ref.json', $ref); out(['ref' => $ref]);
}
$di = dict_idx($ref, $did);
if ($di < 0) fail('Справочник не найден', 404);
$vals = $ref['dicts'][$di]['values'];
$core = in_array($did, ['cats', 'locs', 'ltypes'], true);
$usedCols = array_keys(array_filter(cols_map($ref), fn($c) => $c['type'] === 'select' && $c['dict'] === $did));
$lists = load_lists();
switch ($op) {
case 'rename_dict':
if ($v === '') fail('Введите название');
foreach ($ref['dicts'] as $d) if ($d['id'] !== $did && mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть');
$ref['dicts'][$di]['name'] = $v; break;
case 'delete_dict':
if ($core) fail('Встроенный справочник удалить нельзя');
if ($usedCols) fail('Справочник используется в колонках — сначала удалите или измените их');
array_splice($ref['dicts'], $di, 1); break;
case 'add_value':
if ($v === '' || in_array($v, $vals, true)) fail('Пустое или повторяющееся значение');
$ref['dicts'][$di]['values'][] = $v; break;
case 'rename_value':
$i = array_search($old, $vals, true);
if ($i === false || $v === '' || ($v !== $old && in_array($v, $vals, true))) fail('Пустое или повторяющееся значение');
$ref['dicts'][$di]['values'][$i] = $v;
foreach ($lists as &$l) foreach ($l['items'] as &$it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) $it['v'][$c] = $v;
unset($l, $it);
if ($did === 'ltypes') foreach ($lists as &$l) if (($l['ltype'] ?? '') === $old) $l['ltype'] = $v;
unset($l); wr('lists.json', $lists); break;
case 'delete_value':
$i = array_search($old, $vals, true);
if ($i === false) fail('Не найдено', 404);
if ($core && count($vals) < 2) fail('Нельзя удалить последнее значение');
if ($did === 'ltypes') foreach ($lists as $l) if (($l['ltype'] ?? '') === $old) fail("Тип «{$old}» назначен спискам (например, «{$l['name']}»). Сначала смените им тип.");
foreach ($lists as $l) foreach ($l['items'] as $it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) fail("«{$old}» используется в предметах");
array_splice($ref['dicts'][$di]['values'], (int)$i, 1); break;
default: fail('op');
}
wr('ref.json', $ref); out(['ref' => $ref]);
case 'col_op':
if (!$admin) fail('forbidden', 403);
$op = (string)($in['op'] ?? ''); $cid = (string)($in['id'] ?? '');
$name = str($in['name'] ?? '', 60); $type = (string)($in['type'] ?? ''); $dict = $in['dict'] ?? null;
$lists = load_lists();
$usedIn = function (string $id) use ($lists) { return array_values(array_map(fn($l) => $l['name'], array_filter($lists, fn($l) => in_array($id, $l['cols'], true)))); };
$checkName = function () use ($ref, $name, $cid) {
if ($name === '') fail('Введите название колонки');
foreach (cols_map($ref) as $c) if ($c['id'] !== $cid && mb_strtolower($c['name']) === mb_strtolower($name)) fail('Колонка с таким названием уже есть');
};
$checkType = function () use ($ref, $type, &$dict) {
if (!in_array($type, COL_TYPES, true)) fail('Неверный тип колонки');
if ($type === 'select') { if ((string)$dict === 'ltypes' || dict_idx($ref, (string)$dict) < 0) fail('Выберите справочник для списка'); $dict = (string)$dict; }
else $dict = null;
};
if ($op === 'add') {
$checkName(); $checkType();
if (count($ref['columns']) >= 50) fail('Слишком много колонок');
$ref['columns'][] = ['id' => new_id('c'), 'name' => $name, 'type' => $type, 'dict' => $dict];
} else {
$ci = -1; foreach ($ref['columns'] as $i => $c) if ($c['id'] === $cid) $ci = $i;
if ($ci < 0) fail('Колонка не найдена', 404);
if ($op === 'update') {
$checkName();
$c = $ref['columns'][$ci];
$dict = $dict === '' ? null : $dict;
if ($type !== $c['type'] || ($type === 'select' && (string)$dict !== (string)$c['dict'])) {
if ($u = $usedIn($cid)) fail('Тип колонки нельзя менять: она уже используется в списках (' . implode(', ', array_slice($u, 0, 3)) . ')');
$checkType();
$ref['columns'][$ci]['type'] = $type; $ref['columns'][$ci]['dict'] = $dict;
}
$ref['columns'][$ci]['name'] = $name;
} elseif ($op === 'delete') {
if ($u = $usedIn($cid)) fail('Колонка используется в списках: ' . implode(', ', array_slice($u, 0, 3)));
array_splice($ref['columns'], $ci, 1);
} else fail('op');
}
wr('ref.json', $ref); out(['ref' => $ref]);
default: fail('unknown');
}