diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8899dc9..90bb4e3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -21,6 +21,9 @@ jobs: env: PORTAINER_URL: https://ptr.byte-mate.ru PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + # Секреты Gitea: Settings → Actions → Secrets → APP_USER / APP_PASSWORD + APP_USER: ${{ secrets.APP_USER }} + APP_PASSWORD: ${{ secrets.APP_PASSWORD }} STACK_NAME: photo-editor # Если не задано — берётся первый endpoint из /api/endpoints ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }} @@ -35,6 +38,12 @@ jobs: AUTH="X-API-Key: ${PORTAINER_API_KEY}" + # Параметры стека: пустые значения означают «дефолты на сервере» + ENV_JSON=$(jq -nc \ + --arg u "${APP_USER:-}" \ + --arg p "${APP_PASSWORD:-}" \ + '[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p}]') + # Определяем endpoint (окружение Docker), если не задан вручную if [ -z "${ENDPOINT_ID:-}" ]; then ENDPOINT_ID=$(curl -fsSk -H "$AUTH" "$PORTAINER_URL/api/endpoints" | jq -r '.[0].Id') @@ -49,14 +58,19 @@ jobs: if [ -n "${STACK_ID}" ]; then echo "Обновляю стек ${STACK_NAME} (id=${STACK_ID})" + PAYLOAD=$(jq -nc \ + --rawfile content deploy/docker-compose.yml \ + --argjson env "$ENV_JSON" \ + '{StackFileContent:$content, Env:$env, RepullImageAndRedeploy:true}') curl -fsSk -X PUT -H "$AUTH" -H 'Content-Type: application/json' \ - -d "{\"StackFileContent\": $(jq -Rs . deploy/docker-compose.yml), \"RepullImageAndRedeploy\": true}" \ + -d "$PAYLOAD" \ "$PORTAINER_URL/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" >/dev/null else echo "Создаю стек ${STACK_NAME}" curl -fsSk -X POST -H "$AUTH" \ -F "Name=${STACK_NAME}" \ -F "file=@deploy/docker-compose.yml" \ + -F "Env=${ENV_JSON}" \ "$PORTAINER_URL/api/stacks/create/standalone/file?endpointId=${ENDPOINT_ID}" >/dev/null fi diff --git a/README.md b/README.md index f6d714c..d190015 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,40 @@ npm install npm start ``` +После запуска редактор закрыт cookie-сессией. По умолчанию логин — `admin`, +а пароль генерируется при старте сервера и печатается в консоль (баннер +`APP_PASSWORD не задан. Сгенерирован случайный пароль`). Чтобы зафиксировать +учётные данные, задайте переменные окружения `APP_USER` / `APP_PASSWORD`. +Подробнее — в разделе «Авторизация». + +## Авторизация + +Редактор «Кадр» закрыт cookie-сессией: все `/api/*` эндпоинты, кроме +`/api/health`, `/api/login`, `/api/me`, `/api/logout`, требуют валидную +сессию, иначе возвращают `401 {"ok":false,"error":"Требуется авторизация."}`. +На главной странице поверх редактора показывается форма входа, которая +блокирует работу, пока владелец не войдёт. Статические файлы и `/api/health` +остаются публичными. + +Учётные данные задаются переменными окружения: + +- `APP_USER` — логин владельца (по умолчанию `admin`). +- `APP_PASSWORD` — пароль. Если не задан или пуст, сервер генерирует + случайный 16-символьный пароль при старте и печатает его в консоль/лог + контейнера (баннер `APP_PASSWORD не задан. Сгенерированный пароль`). + +Сессия — cookie `kadr_session`: + +- срок жизни 7 суток; +- скользящая (sliding): продлевается при каждой активности; +- `HttpOnly`, `SameSite=Lax`, без `Secure` (приложение работает по HTTP + в локальной сети); +- сессии хранятся в памяти сервера — перезапуск сбрасывает все входы. + +Публичные эндпоинты: `GET /api/health`, `POST /api/login`, +`GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login` +с JSON-телом `{"username": "...", "password": "..."}`. + ## Docker Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI @@ -54,6 +88,8 @@ docker run -d --name kadr-photo-editor -p 3000:3000 ^ -v "$env:USERPROFILE\.codex:/root/.codex" ^ -v "${PWD}\history:/app/history" ^ -e COMFY_URL=http://192.168.31.240:8188 ^ + -e APP_USER=admin ^ + -e APP_PASSWORD=your-strong-password ^ kadr-photo-editor ``` @@ -83,6 +119,8 @@ docker run -d --name kadr-photo-editor -p 3000:3000 ^ -v "$env:USERPROFILE\.codex:/root/.codex" ^ -v "${PWD}\history:/app/history" ^ -e COMFY_URL=http://192.168.31.240:8188 ^ + -e APP_USER=admin ^ + -e APP_PASSWORD=your-strong-password ^ git.byte-mate.ru/coder/photo-editor:latest ``` @@ -141,6 +179,13 @@ Workflow `.gitea/workflows/deploy.yml` разворачивает сервис в ChatGPT API через прокси. Переменные `HTTP(S)_PROXY` и `NO_PROXY` прописаны в `deploy/docker-compose.yml` (внутренние адреса — ComfyUI, свой хост — идут напрямую). +6. **Авторизация владельца**: секреты `APP_USER` / `APP_PASSWORD` + (Settings → Actions → Secrets) передаются workflow deploy-portainer + в переменные окружения стека. Если секреты не заданы (пустые значения), + сервер использует дефолты: логин `admin` и сгенерированный при старте + пароль, напечатанный в лог контейнера. Альтернатива — задать переменные + вручную в Portainer UI: стек `photo-editor` → **Edit** → **Environment + variables** → `APP_USER` / `APP_PASSWORD`. Поведение: если стека нет — создаёт; если есть — обновляет с `RepullImageAndRedeploy=true` (всегда перетягивает свежий образ). Контейнер diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9188db0..f103e4c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -25,6 +25,12 @@ services: # Внутренние адреса ходят напрямую (ComfyUI и свой же хост) - NO_PROXY=localhost,127.0.0.1,192.168.31.240,git.byte-mate.ru,ptr.byte-mate.ru - no_proxy=localhost,127.0.0.1,192.168.31.240,git.byte-mate.ru,ptr.byte-mate.ru + # Авторизация владельца. Передаются workflow deploy.yml из секретов + # APP_USER / APP_PASSWORD; если не заданы/пусты — сервер использует + # дефолты: логин admin и сгенерированный при старте пароль + # (печатается в лог контейнера). + - APP_USER=${APP_USER} + - APP_PASSWORD=${APP_PASSWORD} # - OPENAI_API_KEY=${OPENAI_API_KEY} restart: unless-stopped diff --git a/public/app.js b/public/app.js index e6f4659..296ba7e 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,14 @@ const jobStatusText = document.querySelector("#job-status-text"); const downloadLink = document.querySelector("#download-link"); const downloadCurrent = document.querySelector("#download-current"); +const authOverlay = document.querySelector("#auth-overlay"); +const loginForm = document.querySelector("#login-form"); +const loginUsername = document.querySelector("#login-username"); +const loginPassword = document.querySelector("#login-password"); +const loginError = document.querySelector("#login-error"); +const loginSubmit = document.querySelector("#login-submit"); +const logoutButton = document.querySelector("#logout-button"); + let currentVersionId = null; let historyVersions = []; let sourceObjectUrl = null; @@ -134,6 +142,15 @@ function resetResult() { downloadLink.removeAttribute("download"); } +function clearEditorState() { + historyVersions = []; + currentVersionId = null; + clearSourcePreview(); + resetResult(); + renderHistory(); + updateDownloadCurrent(null); +} + function clearSourcePreview() { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); @@ -196,7 +213,7 @@ async function selectFile(file) { form.append("photo", file, file.name); try { - const response = await fetch("/api/history/import", { + const response = await apiFetch("/api/history/import", { method: "POST", body: form, cache: "no-store" @@ -274,6 +291,21 @@ async function readJsonResponse(response) { return response.json(); } +async function apiFetch(url, options = {}) { + const response = await fetch(url, { + ...options, + credentials: "same-origin" + }); + + if (response.status === 401) { + clearEditorState(); + showAuthOverlay(); + throw new Error("Требуется авторизация."); + } + + return response; +} + async function runJob(kind) { if (isBusy) { return; @@ -315,7 +347,7 @@ async function runJob(kind) { ); try { - const response = await fetch(endpoint, { + const response = await apiFetch(endpoint, { method: "POST", body: form, cache: "no-store" @@ -476,7 +508,7 @@ function selectHistoryVersion(version) { async function loadHistoryOnStart() { try { - const response = await fetch("/api/history", { + const response = await apiFetch("/api/history", { cache: "no-store" }); @@ -507,12 +539,90 @@ async function loadHistoryOnStart() { } } +function showAuthOverlay() { + authOverlay.hidden = false; + logoutButton.hidden = true; + loginError.hidden = true; + loginPassword.value = ""; + loginUsername.focus(); +} + +function hideAuthOverlay() { + authOverlay.hidden = true; + logoutButton.hidden = false; +} + +async function checkAuth() { + try { + const response = await apiFetch("/api/me", { cache: "no-store" }); + const data = await readJsonResponse(response); + + if (data.ok) { + hideAuthOverlay(); + loadHistoryOnStart(); + return; + } + } catch { + // Сеть недоступна или 401 — считаем пользователя не вошедшим. + } + + showAuthOverlay(); +} + +async function submitLogin(event) { + event.preventDefault(); + + loginError.hidden = true; + loginSubmit.disabled = true; + + try { + const response = await fetch("/api/login", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: loginUsername.value.trim(), + password: loginPassword.value + }) + }); + + const data = await readJsonResponse(response); + + if (!response.ok || !data.ok) { + throw new Error(data.error || "Неверный логин или пароль."); + } + + loginPassword.value = ""; + hideAuthOverlay(); + loadHistoryOnStart(); + } catch (error) { + loginError.textContent = + error instanceof Error ? error.message : "Неверный логин или пароль."; + loginError.hidden = false; + loginPassword.focus(); + } finally { + loginSubmit.disabled = false; + } +} + +async function logout() { + try { + await fetch("/api/logout", { method: "POST", credentials: "same-origin" }); + } catch { + // Выходим локально даже при ошибке сети. + } + + clearEditorState(); + showAuthOverlay(); +} + async function checkHealth() { setHealthState("loading", "Проверяем сервер обработки…"); try { const response = await fetch("/api/health", { - cache: "no-store" + cache: "no-store", + credentials: "same-origin" }); const data = await readJsonResponse(response); @@ -601,6 +711,9 @@ promptInput.addEventListener("input", () => { editButton.addEventListener("click", () => runJob("edit")); upscaleButton.addEventListener("click", () => runJob("upscale")); +loginForm.addEventListener("submit", submitLogin); +logoutButton.addEventListener("click", logout); + window.addEventListener("beforeunload", () => { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); @@ -608,4 +721,4 @@ window.addEventListener("beforeunload", () => { }); checkHealth(); -loadHistoryOnStart(); +checkAuth(); diff --git a/public/index.html b/public/index.html index a60678c..417806b 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E" > - +