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