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" > - +
@@ -24,14 +24,19 @@ Кадр -
- - Проверяем сервер обработки… +
+
+ + Проверяем сервер обработки… +
+
@@ -230,12 +235,32 @@ + + - + diff --git a/public/style.css b/public/style.css index fcf1d75..6311029 100644 --- a/public/style.css +++ b/public/style.css @@ -988,3 +988,123 @@ textarea:disabled { font-size: 13px; color: var(--muted); } + +/* -- Авторизация -------------------------------------------------- */ + +.topbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.logout-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 13px; + border: 1px solid var(--dark); + border-radius: 999px; + color: var(--dark); + background: transparent; + font-size: 12px; + font-weight: 740; + text-decoration: none; + cursor: pointer; + transition: + transform 150ms ease, + background 150ms ease, + color 150ms ease; +} + +.logout-button:hover { + background: var(--dark); + color: white; + transform: translateY(-1px); +} + +.logout-button:focus-visible { + outline: 3px solid rgba(108, 92, 231, 0.28); + outline-offset: 2px; +} + +.auth-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 16px; + background: rgba(243, 240, 232, 0.82); + backdrop-filter: blur(10px); +} + +.auth-card { + width: min(400px, 100%); + padding: 34px 32px; + border: 1px solid rgba(23, 24, 23, 0.08); + border-radius: var(--radius-large); + background: var(--panel); + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.auth-eyebrow { + margin: 0; + color: var(--accent-dark); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.auth-card h1 { + margin: 0; + font-size: clamp(26px, 4vw, 34px); + letter-spacing: -0.045em; +} + +.auth-lead { + margin: 0; + color: var(--muted); + font-size: 13.5px; + line-height: 1.55; +} + +.auth-field { + display: grid; + gap: 8px; +} + +.auth-field input { + width: 100%; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--paper); + color: var(--ink); + outline: none; + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +.auth-field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1); +} + +.auth-error { + margin: 0; + padding: 10px 12px; + border-radius: var(--radius-small); + color: var(--danger); + background: rgba(184, 67, 67, 0.07); + font-size: 13px; +} + +.auth-submit { + width: 100%; + justify-content: center; + min-height: 52px; +} diff --git a/server.js b/server.js index 985174f..686eabc 100644 --- a/server.js +++ b/server.js @@ -58,6 +58,9 @@ app.use(express.static(path.join(__dirname, "public"), { let codexEditInFlight = false; +// JSON-тело нужно для POST /api/login; multipart обрабатывает multer. +app.use(express.json({ limit: "16kb" })); + class AppError extends Error { constructor(status, message, details = undefined) { super(message); @@ -67,6 +70,96 @@ class AppError extends Error { } } +// --- Авторизация ------------------------------------------------- +const SESSION_COOKIE = "kadr_session"; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 суток + +const APP_USER = String(process.env.APP_USER || "").trim() || "admin"; +const APP_PASSWORD = + process.env.APP_PASSWORD && String(process.env.APP_PASSWORD).length > 0 + ? String(process.env.APP_PASSWORD) + : crypto.randomBytes(12).toString("base64url"); // 16 символов + +if (!process.env.APP_PASSWORD) { + console.log(""); + console.log("========================================================"); + console.log(" APP_PASSWORD не задан. Сгенерирован случайный пароль:"); + console.log(` логин: ${APP_USER}`); + console.log(` пароль: ${APP_PASSWORD}`); + console.log(" Задайте переменные окружения APP_USER / APP_PASSWORD,"); + console.log(" чтобы зафиксировать пароль."); + console.log("========================================================"); + console.log(""); +} + +// token -> { user, expiresAt } +const sessions = new Map(); + +function constantTimeEqual(a, b) { + const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest(); + const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest(); + return crypto.timingSafeEqual(digestA, digestB); +} + +function makeSessionToken() { + return crypto.randomBytes(32).toString("hex"); +} + +function sessionCookieOptions() { + return { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: SESSION_TTL_MS + // secure не задаём: приложение работает по HTTP в LAN + }; +} + +function readSessionToken(request) { + const header = String(request.headers.cookie || ""); + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const name = part.slice(0, eq).trim(); + if (name === SESSION_COOKIE) { + const value = part.slice(eq + 1).trim(); + try { return decodeURIComponent(value); } catch { return value; } + } + } + return null; +} + +function getSession(request) { + const token = readSessionToken(request); + if (!token) return null; + const session = sessions.get(token); + if (!session) return null; + if (session.expiresAt < Date.now()) { + sessions.delete(token); + return null; + } + return { token, session }; +} + +function requireAuth(request, response, next) { + const found = getSession(request); + if (!found) { + next(new AppError(401, "Требуется авторизация.")); + return; + } + // Sliding: продлеваем сессию при каждой активности + found.session.expiresAt = Date.now() + SESSION_TTL_MS; + response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions()); + next(); +} + +setInterval(() => { + const now = Date.now(); + for (const [token, session] of sessions) { + if (session.expiresAt < now) sessions.delete(token); + } +}, 60 * 60 * 1000).unref(); + function isHeic(file) { const mimetype = String(file?.mimetype || "").toLowerCase(); const extension = path.extname( @@ -1156,6 +1249,46 @@ app.get("/api/health", asyncRoute(async (_request, response) => { } })); +app.post("/api/login", asyncRoute(async (request, response) => { + const username = String(request.body?.username || "").trim(); + const password = String(request.body?.password || ""); + + if (!username || !password) { + throw new AppError(400, "Введите логин и пароль."); + } + + if (!constantTimeEqual(APP_USER, username) || + !constantTimeEqual(APP_PASSWORD, password)) { + throw new AppError(401, "Неверный логин или пароль."); + } + + const token = makeSessionToken(); + sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS }); + response.cookie(SESSION_COOKIE, token, sessionCookieOptions()); + response.json({ ok: true, user: APP_USER }); +})); + +app.get("/api/me", (request, response, next) => { + const found = getSession(request); + if (!found) { + next(new AppError(401, "Требуется авторизация.")); + return; + } + found.session.expiresAt = Date.now() + SESSION_TTL_MS; + response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions()); + response.json({ ok: true, user: found.session.user }); +}); + +app.post("/api/logout", (request, response) => { + const token = readSessionToken(request); + if (token) sessions.delete(token); + response.clearCookie(SESSION_COOKIE, { path: "/" }); + response.json({ ok: true }); +}); + +// Все эндпоинты ниже — только для авторизованных пользователей. +app.use("/api", requireAuth); + app.post( "/api/preview", upload.single("photo"), @@ -1510,7 +1643,16 @@ app.use((error, _request, response, next) => { } } - if (!(error instanceof AppError) && !(error instanceof multer.MulterError)) { + if (error && error.type === "entity.parse.failed") { + status = 400; + message = "Некорректный JSON в теле запроса."; + } + + if ( + !(error instanceof AppError) && + !(error instanceof multer.MulterError) && + !(error && error.type === "entity.parse.failed") + ) { console.error(error); message = "Внутренняя ошибка сервера."; }