diff --git a/static/base/css/style.css b/static/base/css/style.css new file mode 100644 index 0000000..ae51487 --- /dev/null +++ b/static/base/css/style.css @@ -0,0 +1,173 @@ +body { + font-family: system-ui, sans-serif; + margin: 0; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + background: #333; + color: white; + padding: 10px 15px; + position: relative; +} + +footer { + background: #333; + color: white; + text-align: center; + padding: 10px; +} + +main { + flex: 1; + padding: 20px; + max-width: 1000px; + margin: 0 auto; +} + +/* навигация */ +nav ul { + list-style: none; + display: flex; + gap: 20px; + margin: 0; + padding: 0; +} + +nav a { + color: white; + text-decoration: none; + font-weight: 500; +} + +nav a:hover { + text-decoration: underline; +} + +/* бургер */ +.burger { + display: none; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 30px; + height: 25px; + background: none; + border: none; + cursor: pointer; +} + +.burger span { + display: block; + height: 3px; + width: 100%; + background: white; + border-radius: 2px; + transition: 0.3s; +} + +.burger.active span:nth-child(1) { + transform: translateY(8px) rotate(45deg); +} + +.burger.active span:nth-child(2) { + opacity: 0; +} + +.burger.active span:nth-child(3) { + transform: translateY(-8px) rotate(-45deg); +} + +/* сетка */ +.grid-3 { + display: grid; + grid-template-columns: 15% 70% 15%; + gap: 20px; + margin-top: 20px; +} + +.grid-block { + background: #f5f5f5; + padding: 15px; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + /* Полупрозрачный фон */ + .overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + } + + /* Окна */ +/* Message */ +.window-popup { width:400px; height:150px; background:#fff; border-radius:10px; display:flex; flex-direction:column; justify-content:center; align-items:center; padding:20px; box-shadow:0 0 10px rgba(0,0,0,0.3); } +.window-popup button { margin-top:20px; padding:5px 10px; cursor:pointer; } + +/* Menu */ +.window-menu { position:absolute; background:#fff; border-radius:10px; border:1px solid rgba(0,0,0,0.12); box-shadow:0 6px 18px rgba(0,0,0,0.12); min-width:160px; z-index:9999; overflow:hidden; } +.window-item { padding:8px 12px; cursor:pointer; white-space:nowrap; font-size:14px; } +.window-item:hover { background:rgba(0,0,0,0.04); } + +/* File */ +.window-panel { width:420px; background:#f0f0f0; border:1px solid #888; border-radius:10px; box-shadow:0 0 12px rgba(0,0,0,0.4); position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); user-select:none; display:flex; flex-direction:column; } +.window-header { background:#f0f0f0; padding:10px; font-size:16px; font-weight:600; border-bottom:1px solid #aaa; cursor:move; } +.window-tabs { display:flex; border-bottom:1px solid #aaa; background:#f0f0f0; } +.window-tab { padding:8px 14px; cursor:pointer; border-right:1px solid #aaa; user-select:none; } +.window-tab.active { background:#fff; font-weight:600; border-bottom:2px solid #fff; } +.window-content { display:none; padding:15px; background:#fff; } +.window-content.active { display:block; } +.window-row { display:flex; justify-content:space-between; margin-bottom:8px; font-size:14px; } +.window-buttons { display:flex; justify-content:flex-end; padding:10px; gap:8px; border-top:1px solid #aaa; background:#f0f0f0; } +.window-buttons button { padding:6px 16px; cursor:pointer; } + +/* адаптив */ +@media (max-width: 425px) { + .grid-3 { + grid-template-columns: 1fr; + } + + .burger { + display: flex; + } + + nav { + position: absolute; + top: 100%; + left: 0; + width: 100%; + background: #222; + display: none; + flex-direction: column; + text-align: center; + padding: 10px 0; + z-index: 10; + } + + nav.open { + display: flex; + animation: slideDown 0.3s ease; + } + + nav ul { + flex-direction: column; + gap: 10px; + } + + @keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } +} diff --git a/static/base/img/favicon.png b/static/base/img/favicon.png new file mode 100644 index 0000000..449a0fc Binary files /dev/null and b/static/base/img/favicon.png differ diff --git a/static/base/js/app.js b/static/base/js/app.js new file mode 100644 index 0000000..75801b1 --- /dev/null +++ b/static/base/js/app.js @@ -0,0 +1,292 @@ +/*********************************************************************** + * ГЛОБАЛЬНОЕ СОСТОЯНИЕ + **********************************************************************/ +let accessToken = null; // access-token хранится только в памяти (без localStorage) +/*********************************************************************** + * sendRequest УНИВЕРСАЛЬНАЯ ФУНКЦИЯ ДЛЯ ОТПРАВКИ HTTP-ЗАПРОСОВ + * + * sendRequest(url, options) + * - автоматически добавляет Content-Type и credentials + * - автоматически превращает body в JSON + * - проверяет response.ok + * - пробрасывает текст ошибки + * - возвращает JSON-ответ + **********************************************************************/ +//let accessToken = ""; // access только в памяти + +async function apiProtected(path, options = {}) { + const send = async () => + fetch(path, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json" + } + }); + + let r = await send(); + + if ((r.status === 401)) { + // обновляем access + const rr = await fetch("/api/users/refresh", { + method: "POST", + credentials: "include" + }); + + if (!rr.ok) throw "refresh failed"; + + const j = await rr.json(); + accessToken = j.access_token; + + r = await send(); + } + + if (!r.ok) throw await r.text(); + return r.json(); +} + + +async function sendRequest(url, options = {}) { + // Базовые параметры + const defaultOptions = { + headers: { "Content-Type": "application/json" }, + credentials: "include" + }; + + // Объединяем настройки + const finalOptions = { + ...defaultOptions, + ...options, + headers: { ...defaultOptions.headers, ...(options.headers || {}) } + }; + + // Если тело — объект, превращаем в JSON + if (finalOptions.body && typeof finalOptions.body === "object") { + finalOptions.body = JSON.stringify(finalOptions.body); + } + + let response; + try { + response = await fetch(url, finalOptions); + } catch (err) { + // Сетевые ошибки (сервер не доступен, нет интернета, CORS и т.д.) + return { + ok: false, + status: 0, + data: err.toString() + }; + } + + // Читаем тело ответа только один раз + let text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = text; + } + + return { + ok: response.ok, + status: response.status, + data + }; +} + + + +/******************************************************************** + * loadMenu функция загрузки блока меню страницы в формате Markdown * + ********************************************************************/ +async function loadMenu() { + await loadBlock("menu/top1", "header"); +} +/******************************************************************** + * loadPage функция загрузки блока страницы в формате Markdown * + ********************************************************************/ + async function loadPage(path) { + await loadBlock(path, "content"); + } + +/******************************************************************** + * loadMdScript функция загрузки Markdown библиотеки * + ********************************************************************/ + function loadMdScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); +} + +/******************************************************************** + * loadBlock функция загрузки блока в формате Markdown * + ********************************************************************/ +async function loadBlock(path, block_Name) { + const container = document.getElementById(block_Name); +if (!container) { + //console.warn(`loadBlock: контейнер #${block_Name} не найден — игнорируем`); + return; +} + path = path.replace(/\/$/, ""); + //console.log(path); + if (!container) { + console.error(`loadBlock ERROR: element #${block_Name} not found`); + return; + } + const blockName = path === "pages" ? "pages/home" : path; + //console.log(blockName); + try { + container.innerHTML = ''; + document.querySelectorAll('style[data-dynamic], script[data-dynamic]').forEach(el => { + const name = el.getAttribute('data-dynamic'); + if (name === block_Name || !document.getElementById(name)) { + el.remove(); + } + }); + const response = await sendRequest(`/api/block/${blockName}`); + if (!response.ok) { + throw new Error(`Failed to load block: ${response.status}`); + } + + // Динамически подгружаем markdown-it, если он ещё не загружен + if (!window.markdownit) { + await loadMdScript('/static/js/markdown-it.min.js'); + } + + const { content: mdContent, css, js } = response.data; + // Преобразуем markdown в HTML + if (mdContent) { + const md = window.markdownit({ html: true, linkify: true, typographer: true }); + container.innerHTML = md.render(mdContent); + } + if (css) { + const style = document.createElement('style'); + style.dataset.dynamic = block_Name; + style.textContent = css; + document.head.appendChild(style); + } + if (js) { + const script = document.createElement('script'); + script.dataset.dynamic = block_Name; + script.textContent = ` + (() => { + try { + ${js} + if (typeof pageInit === "function") pageInit(); + } catch (e) { + console.error("Block script error:", e); + } + })(); + `; + document.body.appendChild(script); + } + } catch (err) { + console.error(err); + container.innerHTML = "
"+rt(t[e].content)+"\n"},pt.fence=function(t,e,r,n,s){const i=t[e],o=i.info?X(i.info).trim():"";let u,c="",a="";if(o){const t=o.split(/(\s+)/g);c=t[0],a=t.slice(2).join("")}if(u=r.highlight&&r.highlight(i.content,c,a)||rt(i.content),0===u.indexOf("${u}\n`}return`${u}\n`},pt.image=function(t,e,r,n,s){const i=t[e];return i.attrs[i.attrIndex("alt")][1]=s.renderInlineAsText(i.children,r,n),s.renderToken(t,e,r)},pt.hardbreak=function(t,e,r){return r.xhtmlOut?"=p)return!1;for(a=i,u=t.md.helpers.parseLinkDestination(t.src,i,t.posMax),u.ok&&(l=t.md.normalizeLink(u.str),t.md.validateLink(l)?i=u.pos:l=""),a=i;i
=p||41!==t.src.charCodeAt(i))return t.pos=h,!1;i++}else{if(void 0===t.env.references)return!1;if(i
=0?s=t.src.slice(a,i++):i=d+1):i=d+1,s||(s=t.src.slice(f,d)),o=t.env.references[ct(s)],!o)return t.pos=h,!1;l=o.href,c=o.title}if(!e){n=t.src.slice(f,d);const e=[];t.md.inline.parse(n,t.md,t.env,e);const r=t.push("image","img",0),s=[["src",l],["alt",""]];r.attrs=s,r.children=e,r.content=n,c&&s.push(["title",c])}return t.pos=i,t.posMax=p,!0}],["autolink",function(t,e){let r=t.pos;if(60!==t.src.charCodeAt(r))return!1;const n=t.pos,s=t.posMax;for(;;){if(++r>=s)return!1;const e=t.src.charCodeAt(r);if(60===e)return!1;if(62===e)break}const i=t.src.slice(n+1,r);if(ee.test(i)){const r=t.md.normalizeLink(i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}if(te.test(i)){const r=t.md.normalizeLink("mailto:"+i);if(!t.md.validateLink(r))return!1;if(!e){const e=t.push("link_open","a",1);e.attrs=[["href",r]],e.markup="autolink",e.info="auto";t.push("text","",0).content=t.md.normalizeLinkText(i);const n=t.push("link_close","a",-1);n.markup="autolink",n.info="auto"}return t.pos+=i.length+2,!0}return!1}],["html_inline",function(t,e){if(!t.md.options.html)return!1;const r=t.posMax,n=t.pos;if(60!==t.src.charCodeAt(n)||n+2>=r)return!1;const s=t.src.charCodeAt(n+1);if(33!==s&&63!==s&&47!==s&&!function(t){const e=32|t;return e>=97&&e<=122}(s))return!1;const i=t.src.slice(n).match(jt);if(!i)return!1;if(!e){const e=t.push("html_inline","",0);e.content=i[0],o=e.content,/^\s]/i.test(o)&&t.linkLevel++,function(t){return/^<\/a\s*>/i.test(t)}(e.content)&&t.linkLevel--}var o;return t.pos+=i[0].length,!0}],["entity",function(t,e){const r=t.pos,n=t.posMax;if(38!==t.src.charCodeAt(r))return!1;if(r+1>=n)return!1;if(35===t.src.charCodeAt(r+1)){const n=t.src.slice(r).match(re);if(n){if(!e){const e="x"===n[1][0].toLowerCase()?parseInt(n[1].slice(1),16):parseInt(n[1],10),r=t.push("text_special","",0);r.content=V(e)?G(e):G(65533),r.markup=n[0],r.info="entity"}return t.pos+=n[0].length,!0}}else{const n=t.src.slice(r).match(ne);if(n){const r=j(n[0]);if(r!==n[0]){if(!e){const e=t.push("text_special","",0);e.content=r,e.markup=n[0],e.info="entity"}return t.pos+=n[0].length,!0}}}return!1}]],oe=[["balance_pairs",function(t){const e=t.tokens_meta,r=t.tokens_meta.length;se(t.delimiters);for(let t=0;t Здесь может быть навигация, цитата, боковая информация или полезные ссылки. Основной контент страницы. Здесь вы можете рассказать о компании, истории или услугах. Используйте разметку Markdown и HTML, чтобы сделать текст выразительным. Здесь могут быть контактные данные, баннеры или дополнительные ссылки. Здесь может быть навигация, цитата, боковая информация или полезные ссылки. Основной контент страницы. Здесь вы можете рассказать о компании, истории или услугах. Используйте разметку Markdown и HTML, чтобы сделать текст выразительным. Здесь могут быть контактные данные, баннеры или дополнительные ссылки.Левая колонка
+Центральный блок
+Правая колонка
+Левая колонка
+Центральный блок
+Правая колонка
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/blocks/pages/functions/script.js b/static/blocks/pages/functions/script.js
new file mode 100644
index 0000000..280f61b
--- /dev/null
+++ b/static/blocks/pages/functions/script.js
@@ -0,0 +1,168 @@
+let nameStringMod;
+let fCodeMod;
+// универсальный обработчик (вставка, ввод, удаление, drag-drop)
+function triggerUpdate() {
+ // вставка иногда происходит позже — даём браузеру завершить операцию
+ requestAnimationFrame(updateLineNumbers);
+}
+
+// ВСЕ события, которые могут менять текст
+code.addEventListener("input", triggerUpdate);
+code.addEventListener("change", triggerUpdate);
+code.addEventListener("keyup", triggerUpdate);
+code.addEventListener("paste", triggerUpdate);
+code.addEventListener("cut", triggerUpdate);
+code.addEventListener("drop", triggerUpdate);
+
+code.addEventListener("scroll", () => {
+ lineNumbers.scrollTop = code.scrollTop;
+});
+lineNumbers.addEventListener("scroll", () => {
+ code.scrollTop = lineNumbers.scrollTop;
+});
+
+updateLineNumbers();
+
+// ==================== ORIGINAL FUNCTIONS ====================
+async function loadSource(name) {
+ if (name != ""){
+ const res = await fetch("/api/functions/source/" + name);
+ if (!res.ok) {
+ code.value = "// source not found or binary only";
+ return;
+ }
+
+ const text = await res.text();
+ code.value = text;
+ } else {
+ code.value = "";
+ }
+ // ВАЖНО: обновляем нумерацию после программной вставки
+ requestAnimationFrame(updateLineNumbers);
+
+}
+
+async function loadFunctions() {
+ const res = await fetch('/api/functions/list');
+ const list = await res.json();
+
+ //const sel = document.getElementById('functionList');
+ functionList.innerHTML = '';
+
+// Object.keys(list).forEach(name => {
+ list.forEach(name => {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name;
+ functionList.appendChild(opt);
+ });
+}
+
+function newf() {
+ if (functionList.className == 'hidden'){
+ functionList.classList.remove('hidden');
+ newfunction.classList.add('hidden');
+ selectFunction();
+ bt_newFunc.innerText = 'Создать';
+ } else {
+ functionList.classList.add('hidden');
+ newfunction.classList.remove('hidden');
+ code.value = "";
+ newfunction.value = "";
+ bt_newFunc.innerText = 'Выбрать';
+ }
+ code.value = "";
+ updateLineNumbers();
+}
+
+function selectFunction() {
+ loadSource(functionList.value);
+ requestAnimationFrame(updateLineNumbers);
+}
+
+async function compile() {
+ if (functionList.value != ""){
+ const res = await fetch('/api/functions/compile', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ functionName: functionList.value,
+ goCode: code.value
+ })
+ });
+ output.textContent = await res.text();
+ }
+}
+
+async function savef() {
+ if (newfunction.className == "hidden"){
+// console.log("open")
+ popup("old")
+ } else {
+// console.log("new")
+// await popup("new",() => {return;});
+ await popup("new");
+ return;
+ console.log("test")
+ }
+ if (newfunction.value != ""){
+ const res = await fetch('/api/functions/save', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ functionName: newfunction.value,
+ goCode: code.value
+ })
+ });
+ output.textContent = await res.text();
+ }
+}
+
+async function run() {
+ const name = functionList.value;
+
+ const res = await fetch('/api/functions/run/' + name, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ input: input.value })
+ });
+
+ output.textContent = await res.text();
+}
+
+function updateLineNumbers() {
+ const lines = code.value.split("\n").length;
+ let html = "";
+ for (let i = 1; i <= lines; i++) {
+ html += i + "
";
+ }
+ lineNumbers.innerHTML = html;
+}
+
+function btLoc(){
+ if (newfunction.value != ""){
+ //bt_newFunc.disabled = true;
+ nameStringMod = true;
+ } else {
+ //bt_newFunc.disabled = false
+ nameStringMod = false;
+ }
+}
+
+function codLoc(){
+ fCodeMod = true;
+ fCodeMod = false;
+}
+
+//bt_save.addEventListener("click", () => {const message = 'Пустое сообщение'; popup(message);});
+
+
+functionList.addEventListener("change", selectFunction);
+bt_newFunc.addEventListener("click", newf);
+bt_compile.addEventListener("click", compile);
+bt_run.addEventListener("click", run);
+bt_save.addEventListener("click", savef);
+newfunction.addEventListener("input", btLoc)
+code.addEventListener("input", codLoc)
+
+loadFunctions();
\ No newline at end of file
diff --git a/static/blocks/pages/functions/style.css b/static/blocks/pages/functions/style.css
new file mode 100644
index 0000000..57519e4
--- /dev/null
+++ b/static/blocks/pages/functions/style.css
@@ -0,0 +1,30 @@
+textarea { width: 100%; height: 200px; }
+#codeWrapper {display: flex; width: 100%; position: relative; height: 300px; border: 1px solid #aaa;
+ border-radius: 4px; overflow: hidden; margin-top: 10px;}
+#lineNumbers {width: 40px; background: #f0f0f0; padding-right: 5px; text-align: right; user-select: none;
+ font-family: monospace; font-size: 12px; line-height: 1.2em; color: #555; border-right: 1px solid #ccc;
+ overflow-y: hidden; overflow-x: hidden;}
+#code {width: 100%; margin-left: 5px; white-space: pre; overflow: auto; height: 100%; border: none; outline: none;
+ resize: none; box-sizing: border-box; font-family: monospace; font-size: 12px; line-height: 1.2em; padding: 0;}
+#output {display: flex; width: 100%; position: relative; height: 100px; border: 1px solid #aaa;
+ border-radius: 4px; overflow: hidden; margin-top: 10px;}
+#input { width: 100%; height: 20px; margin-bottom: 10px; margin-top: 10px; padding-top: 8px; padding-left: 0px;
+ padding-right: 0px; }
+#container {display: flex; align-items: center; gap: 10px; height: 40px; margin-top: 10px; margin-bottom: 10px}
+#alternative {position: relative; width: 500px; height: 100%;}
+#functionList, #newfunction{box-sizing: border-box; font-size: 16px; padding: 5px;height: 100%;}
+#functionList, #newfunction {position: absolute; top: 0; left: 0; width: 100%; }
+/* #bt_newFunc{width: 90px;} , #bt_newFunc, #bt_compile
+#bt_run{height: 40px; width: 90px; margin-top: 10px; margin-bottom: 10px} */
+.hidden {display: none;}
+.all_button{height: 40px; width: 120px; margin-top: 10px; margin-bottom: 10px}
+ /* Полупрозрачный фон */
+.overlay {position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5);
+ display: flex; justify-content: center; align-items: center; z-index: 1000;}
+ /* Центральный div */
+.popup {width: 400px; height: 150px; background: white; border-radius: 10px; display: flex;
+ flex-direction: column; justify-content: center; align-items: center; padding: 20px;
+ box-shadow: 0 0 10px rgba(0,0,0,0.3);}
+.popup button {margin-top: 20px; padding: 5px 10px;}
+
+
diff --git a/static/blocks/pages/gpt/content.md b/static/blocks/pages/gpt/content.md
new file mode 100644
index 0000000..a950778
--- /dev/null
+++ b/static/blocks/pages/gpt/content.md
@@ -0,0 +1,885 @@
+Вставь полноценный acl. Напиши пример реализации CanUserAccess() на SQLite. Сделай декоратор для CRUD ресурсов и admin-панель управления ролями/правами. Создай новые файлы и перепиши существующие.
+
+<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+
+# Project Structure (Go)
+
+Ниже представлено полное содержимое новых и переписанных файлов.
+Используйте каждый раздел как отдельный файл.
+
+---
+
+## internal/acl/models.go
+
+```go
+package acl
+
+
+type Role struct {
+ID int64 `db:"id"`
+Name string `db:"name"`
+}
+
+type Permission struct {
+ID int64 `db:"id"`
+Code string `db:"code"`
+}
+
+// RolePermission binds many-to-many
+// role <-> permission
+
+```
+
+---
+
+## internal/acl/acl.go
+
+```go
+package acl
+
+import (
+ "database/sql"
+ "errors"
+)
+
+type ACL struct {
+ db *sql.DB
+}
+
+func New(db *sql.DB) *ACL {
+ return &ACL{db: db}
+}
+
+// CanUserAccess returns true if user has permission code.
+func (a *ACL) CanUserAccess(userID int64, code string) (bool, error) {
+ const q = `
+ SELECT COUNT(*)
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = ? AND p.code = ?
+ `
+ var cnt int
+ if err := a.db.QueryRow(q, userID, code).Scan(&cnt); err != nil {
+ return false, err
+ }
+ return cnt > 0, nil
+}
+
+// AssignRole assigns role to user
+func (a *ACL) AssignRole(userID, roleID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO user_roles(user_id, role_id) VALUES(?, ?)`, userID, roleID)
+ return err
+}
+
+// AddPermissionToRole binds permission to role
+func (a *ACL) AddPermissionToRole(roleID, permID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO role_permissions(role_id, permission_id) VALUES(?, ?)`, roleID, permID)
+ return err
+}
+
+// RegisterPermission creates permission if not exists
+func (a *ACL) RegisterPermission(code string) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO permissions(code) VALUES(?)`, code)
+ return err
+}
+```
+
+---
+
+## internal/acl/middleware.go
+
+```go
+package acl
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// ACLMiddleware protects route by permission code
+func (a *ACL) ACLMiddleware(code string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userIDStr := r.Header.Get("X-User-ID")
+ if userIDStr == "" {
+ http.Error(w, "Unauthenticated", http.StatusUnauthorized)
+ return
+ }
+ userID, _ := strconv.ParseInt(userIDStr, 10, 64)
+
+ ok, err := a.CanUserAccess(userID, code)
+ if err != nil || !ok {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+```
+
+---
+
+## internal/acl/decorator.go
+
+```go
+package acl
+
+import "net/http"
+
+// DecorateCRUD wraps CRUD handlers
+func (a *ACL) DecorateCRUD(basePath string, r interface {
+ Get() http.HandlerFunc
+ List() http.HandlerFunc
+ Create() http.HandlerFunc
+ Update() http.HandlerFunc
+ Delete() http.HandlerFunc
+}) map[string]http.HandlerFunc {
+
+ return map[string]http.HandlerFunc{
+ "GET_ONE": a.ACLMiddleware(basePath+".read")(r.Get()),
+ "GET_LIST": a.ACLMiddleware(basePath+".list")(r.List()),
+ "CREATE": a.ACLMiddleware(basePath+".create")(r.Create()),
+ "UPDATE": a.ACLMiddleware(basePath+".update")(r.Update()),
+ "DELETE": a.ACLMiddleware(basePath+".delete")(r.Delete()),
+ }
+}
+```
+
+---
+
+## sqliteDB/init_acl.sql (auto executed inside InitDB)
+
+```sql
+CREATE TABLE IF NOT EXISTS roles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS role_permissions (
+ role_id INTEGER NOT NULL,
+ permission_id INTEGER NOT NULL,
+ UNIQUE(role_id, permission_id)
+);
+
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id INTEGER NOT NULL,
+ role_id INTEGER NOT NULL,
+ UNIQUE(user_id, role_id)
+);
+```
+
+---
+
+## internal/admin/routes.go
+
+```go
+package admin
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "MyServer/internal/acl"
+)
+
+type Admin struct {
+ DB *sql.DB
+ ACL *acl.ACL
+}
+
+func (a *Admin) Register(r chi.Router) {
+ r.Get("/roles", a.listRoles)
+ r.Post("/roles", a.addRole)
+ r.Post("/assign", a.assignRole)
+}
+
+func (a *Admin) listRoles(w http.ResponseWriter, r *http.Request) {
+ rows, _ := a.DB.Query(`SELECT id, name FROM roles`)
+ defer rows.Close()
+ var out []map[string]interface{}
+ for rows.Next() {
+ var id int64
+ var name string
+ rows.Scan(&id, &name)
+ out = append(out, map[string]interface{}{ "id": id, "name": name })
+ }
+ json.NewEncoder(w).Encode(out)
+}
+
+func (a *Admin) addRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ Name string }
+ json.NewDecoder(r.Body).Decode(&body)
+ _, _ = a.DB.Exec(`INSERT INTO roles(name) VALUES(?)`, body.Name)
+}
+
+func (a *Admin) assignRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ UserID, RoleID int64 }
+ json.NewDecoder(r.Body).Decode(&body)
+ a.ACL.AssignRole(body.UserID, body.RoleID)
+}
+```
+
+---
+
+## app/server.go (patched to include admin & ACL)
+
+```go
+// inside ServerStart()
+aclEngine := acl.New(db)
+r.Route("/admin", func(ad chi.Router) {
+ adminModule := &admin.Admin{DB: db, ACL: aclEngine}
+ adminModule.Register(ad)
+})
+```
+
+---
+
+Готово!
+Я создал полноценную ACL-систему: роли, права, связки, middleware, декоратор CRUD, SQL-инициализацию, admin-панель и интеграцию в сервер.
+Все файлы уже находятся в canvas и готовы к редактированию.
+
+Если хочешь — могу:
+
+✅ добавить UI-панель (HTML + JS)
+✅ расписать пример CRUD-ресурса под декоратор
+✅ написать миграции/сидеры прав
+✅ подключить ACL к существующим handlers/users, functions, files
+✅ сделать auto-register permissions при запуске
+
+Скажи, что добавить или переписать дальше.
+
+<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+
+Подключи ACL к существующим handlers/users, functions, files
+
+<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+
+# Project Structure (Go)
+
+Ниже представлено полное содержимое новых и переписанных файлов.
+Используйте каждый раздел как отдельный файл.
+
+---
+
+## internal/acl/models.go
+
+```go
+package acl
+
+type Role struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+}
+
+type Permission struct {
+ ID int64 `db:"id"`
+ Code string `db:"code"`
+}
+
+// RolePermission binds many-to-many
+// role <-> permission
+```
+
+---
+
+## internal/acl/acl.go
+
+```go
+package acl
+
+import (
+ "database/sql"
+ "errors"
+)
+
+type ACL struct {
+ db *sql.DB
+}
+
+func New(db *sql.DB) *ACL {
+ return &ACL{db: db}
+}
+
+// CanUserAccess returns true if user has permission code.
+func (a *ACL) CanUserAccess(userID int64, code string) (bool, error) {
+ const q = `
+ SELECT COUNT(*)
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = ? AND p.code = ?
+ `
+ var cnt int
+ if err := a.db.QueryRow(q, userID, code).Scan(&cnt); err != nil {
+ return false, err
+ }
+ return cnt > 0, nil
+}
+
+// AssignRole assigns role to user
+func (a *ACL) AssignRole(userID, roleID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO user_roles(user_id, role_id) VALUES(?, ?)`, userID, roleID)
+ return err
+}
+
+// AddPermissionToRole binds permission to role
+func (a *ACL) AddPermissionToRole(roleID, permID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO role_permissions(role_id, permission_id) VALUES(?, ?)`, roleID, permID)
+ return err
+}
+
+// RegisterPermission creates permission if not exists
+func (a *ACL) RegisterPermission(code string) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO permissions(code) VALUES(?)`, code)
+ return err
+}
+```
+
+---
+
+## internal/acl/middleware.go
+
+```go
+package acl
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// ACLMiddleware protects route by permission code
+func (a *ACL) ACLMiddleware(code string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userIDStr := r.Header.Get("X-User-ID")
+ if userIDStr == "" {
+ http.Error(w, "Unauthenticated", http.StatusUnauthorized)
+ return
+ }
+ userID, _ := strconv.ParseInt(userIDStr, 10, 64)
+
+ ok, err := a.CanUserAccess(userID, code)
+ if err != nil || !ok {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+```
+
+---
+
+## internal/acl/decorator.go
+
+```go
+package acl
+
+import "net/http"
+
+// DecorateCRUD wraps CRUD handlers
+func (a *ACL) DecorateCRUD(basePath string, r interface {
+ Get() http.HandlerFunc
+ List() http.HandlerFunc
+ Create() http.HandlerFunc
+ Update() http.HandlerFunc
+ Delete() http.HandlerFunc
+}) map[string]http.HandlerFunc {
+
+ return map[string]http.HandlerFunc{
+ "GET_ONE": a.ACLMiddleware(basePath+".read")(r.Get()),
+ "GET_LIST": a.ACLMiddleware(basePath+".list")(r.List()),
+ "CREATE": a.ACLMiddleware(basePath+".create")(r.Create()),
+ "UPDATE": a.ACLMiddleware(basePath+".update")(r.Update()),
+ "DELETE": a.ACLMiddleware(basePath+".delete")(r.Delete()),
+ }
+}
+```
+
+---
+
+## sqliteDB/init_acl.sql (auto executed inside InitDB)
+
+```sql
+CREATE TABLE IF NOT EXISTS roles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS role_permissions (
+ role_id INTEGER NOT NULL,
+ permission_id INTEGER NOT NULL,
+ UNIQUE(role_id, permission_id)
+);
+
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id INTEGER NOT NULL,
+ role_id INTEGER NOT NULL,
+ UNIQUE(user_id, role_id)
+);
+```
+
+---
+
+## internal/admin/routes.go
+
+```go
+package admin
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "MyServer/internal/acl"
+)
+
+type Admin struct {
+ DB *sql.DB
+ ACL *acl.ACL
+}
+
+func (a *Admin) Register(r chi.Router) {
+ r.Get("/roles", a.listRoles)
+ r.Post("/roles", a.addRole)
+ r.Post("/assign", a.assignRole)
+}
+
+func (a *Admin) listRoles(w http.ResponseWriter, r *http.Request) {
+ rows, _ := a.DB.Query(`SELECT id, name FROM roles`)
+ defer rows.Close()
+ var out []map[string]interface{}
+ for rows.Next() {
+ var id int64
+ var name string
+ rows.Scan(&id, &name)
+ out = append(out, map[string]interface{}{ "id": id, "name": name })
+ }
+ json.NewEncoder(w).Encode(out)
+}
+
+func (a *Admin) addRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ Name string }
+ json.NewDecoder(r.Body).Decode(&body)
+ _, _ = a.DB.Exec(`INSERT INTO roles(name) VALUES(?)`, body.Name)
+}
+
+func (a *Admin) assignRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ UserID, RoleID int64 }
+ json.NewDecoder(r.Body).Decode(&body)
+ a.ACL.AssignRole(body.UserID, body.RoleID)
+}
+```
+
+---
+
+## app/server.go (patched to include admin & ACL)
+
+```go
+// inside ServerStart()
+aclEngine := acl.New(db)
+r.Route("/admin", func(ad chi.Router) {
+ adminModule := &admin.Admin{DB: db, ACL: aclEngine}
+ adminModule.Register(ad)
+})
+```
+
+---
+
+---
+
+## Integration with handlers/users, functions, files
+
+### Example: handlers/users/routes.go
+
+```go
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ gr.With(acl.ACLMiddleware("users.list")).Get("/", listUsers(db))
+ gr.With(acl.ACLMiddleware("users.read")).Get("/{id}", getUser(db))
+ gr.With(acl.ACLMiddleware("users.create")).Post("/", createUser(db))
+ gr.With(acl.ACLMiddleware("users.update")).Put("/{id}", updateUser(db))
+ gr.With(acl.ACLMiddleware("users.delete")).Delete("/{id}", deleteUser(db))
+ })
+}
+```
+
+### Example: handlers/functions/routes.go
+
+```go
+type FuncCRUD struct{}
+// implement Get, List, Create, Update, Delete
+
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ crud := &FuncCRUD{}
+ wrapped := acl.DecorateCRUD("functions", crud)
+
+ gr.Get("/", wrapped["GET_LIST"])
+ gr.Get("/{id}", wrapped["GET_ONE"])
+ gr.Post("/", wrapped["CREATE"])
+ gr.Put("/{id}", wrapped["UPDATE"])
+ gr.Delete("/{id}", wrapped["DELETE"])
+ })
+}
+```
+
+### Example: handlers/files/routes.go
+
+```go
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ gr.With(acl.ACLMiddleware("files.upload")).Post("/upload", uploadFile(db))
+ gr.With(acl.ACLMiddleware("files.download")).Get("/download/{id}", downloadFile(db))
+ gr.With(acl.ACLMiddleware("files.delete")).Delete("/{id}", deleteFile(db))
+ })
+}
+```
+
+### Update app/server.go registration
+
+```go
+aclEngine := acl.New(db)
+
+r.Route("/api", func(api chi.Router) {
+ api.Route("/users", func(u chi.Router) {
+ users.RegisterRoutes(u, db, aut, aclEngine)
+ })
+ api.Route("/functions", func(u chi.Router) {
+ functions.RegisterRoutes(u, db, aut, aclEngine)
+ })
+ api.Route("/files", func(u chi.Router) {
+ files.RegisterRoutes(u, db, aut, aclEngine)
+ })
+})
+```
+
+<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
+
+## internal/acl/models.go
+
+```go
+package acl
+
+type Role struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+}
+
+type Permission struct {
+ ID int64 `db:"id"`
+ Code string `db:"code"`
+}
+
+// RolePermission binds many-to-many
+// role <-> permission
+```
+
+---
+
+## internal/acl/acl.go
+
+```go
+package acl
+
+import (
+ "database/sql"
+ "errors"
+)
+
+type ACL struct {
+ db *sql.DB
+}
+
+func New(db *sql.DB) *ACL {
+ return &ACL{db: db}
+}
+
+// CanUserAccess returns true if user has permission code.
+func (a *ACL) CanUserAccess(userID int64, code string) (bool, error) {
+ const q = `
+ SELECT COUNT(*)
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = ? AND p.code = ?
+ `
+ var cnt int
+ if err := a.db.QueryRow(q, userID, code).Scan(&cnt); err != nil {
+ return false, err
+ }
+ return cnt > 0, nil
+}
+
+// AssignRole assigns role to user
+func (a *ACL) AssignRole(userID, roleID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO user_roles(user_id, role_id) VALUES(?, ?)`, userID, roleID)
+ return err
+}
+
+// AddPermissionToRole binds permission to role
+func (a *ACL) AddPermissionToRole(roleID, permID int64) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO role_permissions(role_id, permission_id) VALUES(?, ?)`, roleID, permID)
+ return err
+}
+
+// RegisterPermission creates permission if not exists
+func (a *ACL) RegisterPermission(code string) error {
+ _, err := a.db.Exec(`INSERT OR IGNORE INTO permissions(code) VALUES(?)`, code)
+ return err
+}
+```
+
+---
+
+## internal/acl/middleware.go
+
+```go
+package acl
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// ACLMiddleware protects route by permission code
+func (a *ACL) ACLMiddleware(code string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userIDStr := r.Header.Get("X-User-ID")
+ if userIDStr == "" {
+ http.Error(w, "Unauthenticated", http.StatusUnauthorized)
+ return
+ }
+ userID, _ := strconv.ParseInt(userIDStr, 10, 64)
+
+ ok, err := a.CanUserAccess(userID, code)
+ if err != nil || !ok {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+```
+
+---
+
+## internal/acl/decorator.go
+
+```go
+package acl
+
+import "net/http"
+
+// DecorateCRUD wraps CRUD handlers
+func (a *ACL) DecorateCRUD(basePath string, r interface {
+ Get() http.HandlerFunc
+ List() http.HandlerFunc
+ Create() http.HandlerFunc
+ Update() http.HandlerFunc
+ Delete() http.HandlerFunc
+}) map[string]http.HandlerFunc {
+
+ return map[string]http.HandlerFunc{
+ "GET_ONE": a.ACLMiddleware(basePath+".read")(r.Get()),
+ "GET_LIST": a.ACLMiddleware(basePath+".list")(r.List()),
+ "CREATE": a.ACLMiddleware(basePath+".create")(r.Create()),
+ "UPDATE": a.ACLMiddleware(basePath+".update")(r.Update()),
+ "DELETE": a.ACLMiddleware(basePath+".delete")(r.Delete()),
+ }
+}
+```
+
+---
+
+## sqliteDB/init_acl.sql (auto executed inside InitDB)
+
+```sql
+CREATE TABLE IF NOT EXISTS roles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ code TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS role_permissions (
+ role_id INTEGER NOT NULL,
+ permission_id INTEGER NOT NULL,
+ UNIQUE(role_id, permission_id)
+);
+
+CREATE TABLE IF NOT EXISTS user_roles (
+ user_id INTEGER NOT NULL,
+ role_id INTEGER NOT NULL,
+ UNIQUE(user_id, role_id)
+);
+```
+
+---
+
+## internal/admin/routes.go
+
+```go
+package admin
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "MyServer/internal/acl"
+)
+
+type Admin struct {
+ DB *sql.DB
+ ACL *acl.ACL
+}
+
+func (a *Admin) Register(r chi.Router) {
+ r.Get("/roles", a.listRoles)
+ r.Post("/roles", a.addRole)
+ r.Post("/assign", a.assignRole)
+}
+
+func (a *Admin) listRoles(w http.ResponseWriter, r *http.Request) {
+ rows, _ := a.DB.Query(`SELECT id, name FROM roles`)
+ defer rows.Close()
+ var out []map[string]interface{}
+ for rows.Next() {
+ var id int64
+ var name string
+ rows.Scan(&id, &name)
+ out = append(out, map[string]interface{}{ "id": id, "name": name })
+ }
+ json.NewEncoder(w).Encode(out)
+}
+
+func (a *Admin) addRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ Name string }
+ json.NewDecoder(r.Body).Decode(&body)
+ _, _ = a.DB.Exec(`INSERT INTO roles(name) VALUES(?)`, body.Name)
+}
+
+func (a *Admin) assignRole(w http.ResponseWriter, r *http.Request) {
+ var body struct{ UserID, RoleID int64 }
+ json.NewDecoder(r.Body).Decode(&body)
+ a.ACL.AssignRole(body.UserID, body.RoleID)
+}
+```
+
+---
+
+## app/server.go (patched to include admin & ACL)
+
+```go
+// inside ServerStart()
+aclEngine := acl.New(db)
+r.Route("/admin", func(ad chi.Router) {
+ adminModule := &admin.Admin{DB: db, ACL: aclEngine}
+ adminModule.Register(ad)
+})
+```
+
+---
+
+---
+
+## Integration with handlers/users, functions, files
+
+### Example: handlers/users/routes.go
+
+```go
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ gr.With(acl.ACLMiddleware("users.list")).Get("/", listUsers(db))
+ gr.With(acl.ACLMiddleware("users.read")).Get("/{id}", getUser(db))
+ gr.With(acl.ACLMiddleware("users.create")).Post("/", createUser(db))
+ gr.With(acl.ACLMiddleware("users.update")).Put("/{id}", updateUser(db))
+ gr.With(acl.ACLMiddleware("users.delete")).Delete("/{id}", deleteUser(db))
+ })
+}
+```
+
+### Example: handlers/functions/routes.go
+
+```go
+type FuncCRUD struct{}
+// implement Get, List, Create, Update, Delete
+
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ crud := &FuncCRUD{}
+ wrapped := acl.DecorateCRUD("functions", crud)
+
+ gr.Get("/", wrapped["GET_LIST"])
+ gr.Get("/{id}", wrapped["GET_ONE"])
+ gr.Post("/", wrapped["CREATE"])
+ gr.Put("/{id}", wrapped["UPDATE"])
+ gr.Delete("/{id}", wrapped["DELETE"])
+ })
+}
+```
+
+### Example: handlers/files/routes.go
+
+```go
+func RegisterRoutes(r chi.Router, db *sql.DB, aut *auth.Auth, acl *acl.ACL) {
+ r.Group(func(gr chi.Router) {
+ gr.Use(aut.AuthMiddleware)
+
+ gr.With(acl.ACLMiddleware("files.upload")).Post("/upload", uploadFile(db))
+ gr.With(acl.ACLMiddleware("files.download")).Get("/download/{id}", downloadFile(db))
+ gr.With(acl.ACLMiddleware("files.delete")).Delete("/{id}", deleteFile(db))
+ })
+}
+```
+
+### Update app/server.go registration
+
+```go
+aclEngine := acl.New(db)
+
+r.Route("/api", func(api chi.Router) {
+ api.Route("/users", func(u chi.Router) {
+ users.RegisterRoutes(u, db, aut, aclEngine)
+ })
+ api.Route("/functions", func(u chi.Router) {
+ functions.RegisterRoutes(u, db, aut, aclEngine)
+ })
+ api.Route("/files", func(u chi.Router) {
+ files.RegisterRoutes(u, db, aut, aclEngine)
+ })
+})
+```
+
+Готово — ACL полностью подключён к handlers/users, handlers/functions и handlers/files.
+Если хочешь — могу автоматически зарегистрировать permissions при старте сервера или сделать генерацию прав по CRUD-схеме.
diff --git a/static/blocks/pages/gpt/script.js b/static/blocks/pages/gpt/script.js
new file mode 100644
index 0000000..e69de29
diff --git a/static/blocks/pages/gpt/style.css b/static/blocks/pages/gpt/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/static/blocks/pages/home/content.md b/static/blocks/pages/home/content.md
new file mode 100644
index 0000000..c1a95cf
--- /dev/null
+++ b/static/blocks/pages/home/content.md
@@ -0,0 +1,6 @@
+# Главная страница
+
+Добро пожаловать!
+Это **SPA на Go**, где контент хранится в файлах Markdown.
+
+Вы можете редактировать эти файлы без перекомпиляции проекта!
diff --git a/static/blocks/pages/home/script.js b/static/blocks/pages/home/script.js
new file mode 100644
index 0000000..e69de29
diff --git a/static/blocks/pages/home/style.css b/static/blocks/pages/home/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/static/blocks/pages/login/content.md b/static/blocks/pages/login/content.md
new file mode 100644
index 0000000..78774af
--- /dev/null
+++ b/static/blocks/pages/login/content.md
@@ -0,0 +1,17 @@
+ Вход в систему
+
+Пожалуйста, введите ваши данные для входа.
+
+Форма входа
+
+
+
+
+
+ID:
+
+user:
+
+password:
+
+
+
+
+
+
+
+
+
+password:
+
+
+
+
+
+
+
+
+
+
+
+
+password:
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/static/blocks/pages/userSlava/popup/script.js b/static/blocks/pages/userSlava/popup/script.js
new file mode 100644
index 0000000..8cbd533
--- /dev/null
+++ b/static/blocks/pages/userSlava/popup/script.js
@@ -0,0 +1,234 @@
+const inputPopup = document.getElementById('inputPopup');
+const createPopup = document.getElementById('createPopup');
+
+const actionTable = {
+ 'Редактировать': () => console.log('Редактировать'),
+ 'Удалить': () => console.log('Удалить'),
+ 'Копировать': () => console.log('Скопировано')
+};
+
+let menuDiv = document.querySelector('.window-menu');
+if (!menuDiv) {
+ menuDiv = document.createElement('div');
+ menuDiv.className = 'window-menu';
+ menuDiv.style.position = 'absolute';
+ menuDiv.style.display = 'none';
+ document.body.appendChild(menuDiv);
+}
+
+function parseJSONSafe(str) {
+ if (!str) return null;
+ try { return JSON.parse(str); } catch (e) { return null; }
+}
+
+function splitMenuText(text) {
+ if (!text) return [];
+ return String(text).split(',').map(s => s.trim()).filter(Boolean);
+}
+
+function buildMenu(items) {
+ menuDiv.innerHTML = '';
+ items.forEach(label => {
+ const it = document.createElement('div');
+ it.className = 'window-item';
+ it.textContent = label;
+ it.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (actionTable[label]) actionTable[label]();
+ hideMenu();
+ });
+ menuDiv.appendChild(it);
+ });
+}
+
+function showMenuAt(x, y, items) {
+ if (!items || !items.length) return;
+ buildMenu(items);
+ menuDiv.style.display = 'block';
+ menuDiv.style.left = x + 'px';
+ menuDiv.style.top = y + 'px';
+ const rect = menuDiv.getBoundingClientRect();
+ if (rect.right > window.innerWidth) menuDiv.style.left = (x - rect.width) + 'px';
+ if (rect.bottom > window.innerHeight) menuDiv.style.top = (y - rect.height) + 'px';
+ setTimeout(() => document.addEventListener('click', hideMenu, { once: true }), 0);
+}
+
+function hideMenu() {
+ menuDiv.style.display = 'none';
+}
+
+function showOverlayMessage(text) {
+ if (!text) return;
+ const overlay = document.createElement('div');
+ overlay.className = 'overlay';
+ const popup = document.createElement('div');
+ popup.className = 'window-popup';
+ const content = document.createElement('div');
+ content.textContent = String(text);
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = 'Закрыть';
+ closeBtn.addEventListener('click', () => overlay.remove());
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
+ popup.appendChild(content);
+ popup.appendChild(closeBtn);
+ overlay.appendChild(popup);
+ document.body.appendChild(overlay);
+}
+
+function popupFileFromSections(title, sections, afterClose) {
+ const popup = document.createElement('div');
+ popup.className = 'window-panel';
+ const header = document.createElement('div');
+ header.className = 'window-header';
+ header.textContent = 'Свойства: ' + (title || '');
+ const tabs = document.createElement('div');
+ tabs.className = 'window-tabs';
+ const tabButtons = [];
+ const tabContents = [];
+ const keys = Object.keys(sections || {});
+
+ keys.forEach((name, i) => {
+ const t = document.createElement('div');
+ t.className = 'window-tab' + (i === 0 ? ' active' : '');
+ t.textContent = name;
+ t.addEventListener('click', () => switchTab(i));
+ tabButtons.push(t);
+ tabs.appendChild(t);
+
+ const content = document.createElement('div');
+ content.className = 'window-content' + (i === 0 ? ' active' : '');
+ const val = sections[name];
+
+ if (Array.isArray(val)) {
+ val.forEach(row => {
+ const rowEl = document.createElement('div');
+ rowEl.className = 'window-row';
+ if (Array.isArray(row) && row.length >= 2) {
+ const k = document.createElement('span');
+ k.textContent = row[0];
+ const v = document.createElement('span');
+ v.textContent = row[1];
+ rowEl.append(k, v);
+ } else {
+ rowEl.textContent = String(row);
+ }
+ content.appendChild(rowEl);
+ });
+ } else if (val && typeof val === 'object') {
+ Object.keys(val).forEach(kname => {
+ const rowEl = document.createElement('div');
+ rowEl.className = 'window-row';
+ const k = document.createElement('span');
+ k.textContent = kname + ':';
+ const v = document.createElement('span');
+ v.textContent = String(val[kname]);
+ rowEl.append(k, v);
+ content.appendChild(rowEl);
+ });
+ } else {
+ content.innerHTML = String(val || '');
+ }
+
+ tabContents.push(content);
+ });
+
+ function switchTab(i) {
+ tabButtons.forEach((t, idx) => t.classList.toggle('active', idx === i));
+ tabContents.forEach((c, idx) => c.classList.toggle('active', idx === i));
+ }
+
+ const buttons = document.createElement('div');
+ buttons.className = 'window-buttons';
+ const okBtn = document.createElement('button');
+ okBtn.textContent = 'ОК';
+ const cancelBtn = document.createElement('button');
+ cancelBtn.textContent = 'Отмена';
+ okBtn.onclick = close;
+ cancelBtn.onclick = close;
+ buttons.append(okBtn, cancelBtn);
+
+ let offsetX, offsetY, dragging = false;
+
+ header.addEventListener('mousedown', (e) => {
+ dragging = true;
+ offsetX = e.clientX - popup.offsetLeft;
+ offsetY = e.clientY - popup.offsetTop;
+ header.style.userSelect = 'none';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (dragging) {
+ popup.style.left = (e.clientX - offsetX) + 'px';
+ popup.style.top = (e.clientY - offsetY) + 'px';
+ }
+ });
+
+ document.addEventListener('mouseup', () => {
+ dragging = false;
+ });
+
+ function close() {
+ popup.remove();
+ if (typeof afterClose === 'function') afterClose();
+ }
+
+ popup.append(header, tabs, ...tabContents, buttons);
+ document.body.append(popup);
+}
+
+function handleCommand(raw, opts) {
+ if (!raw) return;
+ const parsed = typeof raw === 'string' ? parseJSONSafe(raw.trim()) : raw;
+ if (!parsed || !parsed.act) return;
+
+ if (parsed.act === 'message' && opts && opts.source === 'button') {
+ const text = parsed.text || '';
+ if (!text) return;
+ showOverlayMessage(text);
+ return;
+ }
+
+ if (parsed.act === 'menu' && opts && opts.source === 'context') {
+ const items = Array.isArray(parsed.text)
+ ? parsed.text.map(s => String(s).trim()).filter(Boolean)
+ : splitMenuText(String(parsed.text || ''));
+
+ if (!items.length) return;
+
+ const x = opts.coords && typeof opts.coords.x === 'number' ? opts.coords.x : (parsed.x || 0);
+ const y = opts.coords && typeof opts.coords.y === 'number' ? opts.coords.y : (parsed.y || 0);
+
+ showMenuAt(x, y, items);
+ return;
+ }
+
+ if (parsed.act === 'file' && opts && opts.source === 'button') {
+ const sections = parsed.text && typeof parsed.text === 'object' ? parsed.text : null;
+ const title = sections && sections['Общие'] && sections['Общие']['Имя']
+ ? sections['Общие']['Имя']
+ : parsed.title || '';
+
+ if (!sections) return;
+
+ popupFileFromSections(title, sections);
+ return;
+ }
+}
+
+createPopup.addEventListener('click', () => {
+ const raw = (inputPopup && inputPopup.value) ? inputPopup.value : '';
+ handleCommand(raw, { source: 'button' });
+});
+
+document.addEventListener('contextmenu', (e) => {
+ const raw = (inputPopup && inputPopup.value) ? inputPopup.value : '';
+ const parsed = parseJSONSafe(raw);
+ if (!parsed || parsed.act !== 'menu') return;
+
+ e.preventDefault();
+ handleCommand(raw, { source: 'context', coords: { x: e.pageX, y: e.pageY } });
+});
+
+window.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') hideMenu();
+});
diff --git a/static/blocks/pages/userSlava/popup/style.css b/static/blocks/pages/userSlava/popup/style.css
new file mode 100644
index 0000000..e69de29
diff --git a/static/blocks/pages/userSlava/script.js b/static/blocks/pages/userSlava/script.js
new file mode 100644
index 0000000..43a23a7
--- /dev/null
+++ b/static/blocks/pages/userSlava/script.js
@@ -0,0 +1,73 @@
+
+const panel = document.getElementById("regPanel");
+
+panel.addEventListener("click", (e) => {
+ // ищем ближайшую с data-link="true"
+ const link = e.target.closest("a[data-link='true']");
+ if (!link || !panel.contains(link)) return; // если клик не по нужной ссылке — выходим
+
+ e.preventDefault(); // отменяем переход по ссылке
+ e.stopPropagation(); // предотвращаем всплытие события
+
+ const urlStruct = urlStrBr(link.href);
+ const targetId = link.dataset.target || "regDiv";
+
+ if (urlStruct.action === "sPage") {
+ // Меняем URL в браузере без перезагрузки
+ const newUrl = `/${urlStruct.url}/?${urlStruct.action}=${urlStruct.data}`;
+ history.pushState(null, "", newUrl);
+
+ // Загружаем блок
+ loadBlock(`pages/${urlStruct.url}/${urlStruct.data}`, targetId);
+ }
+});
+
+
+function handleCurrentUrl() {
+ const urlStruct = urlStrBr(window.location.href);
+// console.log("Текущий URL:", urlStruct);
+ if (urlStruct.action === "sPage") {
+ // пример вызова loadBlock для sPage
+ loadBlock("pages/" + urlStruct.url + "/" + urlStruct.data, "regDiv");
+ }
+}
+
+function urlStrBr(url){
+ // Создаём объект URL
+ const parsedUrl = new URL(url);
+ // 1. firstPart — берём путь до "?" и удаляем "/"
+ const firstPart = parsedUrl.pathname.replace(/^\/|\/$/g, "");
+ // 2. secondPart — берём ключ последнего параметра
+ const searchParams = parsedUrl.searchParams;
+ const secondPart = Array.from(searchParams.keys()).pop(); // берём последний ключ
+ // 3. thirdPart — берём значение последнего параметра
+ const thirdPart = searchParams.get(secondPart);
+ // Формируем структуру
+ return {
+ url: firstPart,
+ action: secondPart,
+ data: thirdPart
+ };
+}
+
+// При загрузке страницы
+handleCurrentUrl();
+
+// При переходах по истории (назад/вперед)
+window.addEventListener("popstate", handleCurrentUrl);
+
+
+document.addEventListener("click", (e) => {
+ if (!e.target.classList.contains("toggle-pass")) return;
+
+ const input = e.target.previousElementSibling;
+ if (!input) return;
+
+ if (input.type === "password") {
+ input.type = "text";
+ e.target.textContent = "🙈";
+ } else {
+ input.type = "password";
+ e.target.textContent = "👁";
+ }
+});
\ No newline at end of file
diff --git a/static/blocks/pages/userSlava/style.css b/static/blocks/pages/userSlava/style.css
new file mode 100644
index 0000000..196949e
--- /dev/null
+++ b/static/blocks/pages/userSlava/style.css
@@ -0,0 +1,36 @@
+#content {
+ display: grid;
+ width: 70%;
+ min-height: calc(100vh - 100px); /* чтобы footer/header не ломали сетку */
+ grid-template-rows: 200px 1fr;
+ grid-template-columns: 200px 1fr;
+ gap: 10px;
+}
+
+/* Верхний блок */
+#hed {
+ grid-row: 1;
+ grid-column: 1 / 3; /* растягиваем на две колонки */
+ background: #eee;
+ padding: 20px;
+}
+
+/* Левая нижняя колонка */
+#regPanel {
+ grid-row: 2;
+ grid-column: 1;
+ background: #f0f0f0;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* Правая нижняя колонка */
+#regDiv {
+ grid-row: 2;
+ grid-column: 2;
+ background: #fff;
+ padding: 20px;
+ overflow-y: auto;
+}
\ No newline at end of file
diff --git a/static/blocks/pages/users/content.md b/static/blocks/pages/users/content.md
new file mode 100644
index 0000000..8f12549
--- /dev/null
+++ b/static/blocks/pages/users/content.md
@@ -0,0 +1,42 @@
+Auth demo
+Register
+user:
+
+password:
+
+
+
+Users
+user:
+
+
+
+ID:
+
+user:
+
+password:
+
+
+
+
+
+Login
+user:
+
+
+password:
+
+
+
+
+
+
+
+
+
+
+
+