Авторизация: форма входа, cookie-сессия, защита /api/* (кроме health/login/me/logout)
This commit is contained in:
@@ -21,6 +21,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PORTAINER_URL: https://ptr.byte-mate.ru
|
PORTAINER_URL: https://ptr.byte-mate.ru
|
||||||
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
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
|
STACK_NAME: photo-editor
|
||||||
# Если не задано — берётся первый endpoint из /api/endpoints
|
# Если не задано — берётся первый endpoint из /api/endpoints
|
||||||
ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }}
|
ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }}
|
||||||
@@ -35,6 +38,12 @@ jobs:
|
|||||||
|
|
||||||
AUTH="X-API-Key: ${PORTAINER_API_KEY}"
|
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), если не задан вручную
|
# Определяем endpoint (окружение Docker), если не задан вручную
|
||||||
if [ -z "${ENDPOINT_ID:-}" ]; then
|
if [ -z "${ENDPOINT_ID:-}" ]; then
|
||||||
ENDPOINT_ID=$(curl -fsSk -H "$AUTH" "$PORTAINER_URL/api/endpoints" | jq -r '.[0].Id')
|
ENDPOINT_ID=$(curl -fsSk -H "$AUTH" "$PORTAINER_URL/api/endpoints" | jq -r '.[0].Id')
|
||||||
@@ -49,14 +58,19 @@ jobs:
|
|||||||
|
|
||||||
if [ -n "${STACK_ID}" ]; then
|
if [ -n "${STACK_ID}" ]; then
|
||||||
echo "Обновляю стек ${STACK_NAME} (id=${STACK_ID})"
|
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' \
|
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
|
"$PORTAINER_URL/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" >/dev/null
|
||||||
else
|
else
|
||||||
echo "Создаю стек ${STACK_NAME}"
|
echo "Создаю стек ${STACK_NAME}"
|
||||||
curl -fsSk -X POST -H "$AUTH" \
|
curl -fsSk -X POST -H "$AUTH" \
|
||||||
-F "Name=${STACK_NAME}" \
|
-F "Name=${STACK_NAME}" \
|
||||||
-F "file=@deploy/docker-compose.yml" \
|
-F "file=@deploy/docker-compose.yml" \
|
||||||
|
-F "Env=${ENV_JSON}" \
|
||||||
"$PORTAINER_URL/api/stacks/create/standalone/file?endpointId=${ENDPOINT_ID}" >/dev/null
|
"$PORTAINER_URL/api/stacks/create/standalone/file?endpointId=${ENDPOINT_ID}" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -39,6 +39,40 @@ npm install
|
|||||||
npm start
|
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
|
## Docker
|
||||||
|
|
||||||
Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI
|
Образ собирается на базе `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 "$env:USERPROFILE\.codex:/root/.codex" ^
|
||||||
-v "${PWD}\history:/app/history" ^
|
-v "${PWD}\history:/app/history" ^
|
||||||
-e COMFY_URL=http://192.168.31.240:8188 ^
|
-e COMFY_URL=http://192.168.31.240:8188 ^
|
||||||
|
-e APP_USER=admin ^
|
||||||
|
-e APP_PASSWORD=your-strong-password ^
|
||||||
kadr-photo-editor
|
kadr-photo-editor
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,6 +119,8 @@ docker run -d --name kadr-photo-editor -p 3000:3000 ^
|
|||||||
-v "$env:USERPROFILE\.codex:/root/.codex" ^
|
-v "$env:USERPROFILE\.codex:/root/.codex" ^
|
||||||
-v "${PWD}\history:/app/history" ^
|
-v "${PWD}\history:/app/history" ^
|
||||||
-e COMFY_URL=http://192.168.31.240:8188 ^
|
-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
|
git.byte-mate.ru/coder/photo-editor:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -141,6 +179,13 @@ Workflow `.gitea/workflows/deploy.yml` разворачивает сервис
|
|||||||
в ChatGPT API через прокси. Переменные `HTTP(S)_PROXY` и `NO_PROXY`
|
в ChatGPT API через прокси. Переменные `HTTP(S)_PROXY` и `NO_PROXY`
|
||||||
прописаны в `deploy/docker-compose.yml` (внутренние адреса — ComfyUI,
|
прописаны в `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` (всегда перетягивает свежий образ). Контейнер
|
`RepullImageAndRedeploy=true` (всегда перетягивает свежий образ). Контейнер
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ services:
|
|||||||
# Внутренние адреса ходят напрямую (ComfyUI и свой же хост)
|
# Внутренние адреса ходят напрямую (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
|
||||||
- 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}
|
# - OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
123
public/app.js
123
public/app.js
@@ -36,6 +36,14 @@ const jobStatusText = document.querySelector("#job-status-text");
|
|||||||
const downloadLink = document.querySelector("#download-link");
|
const downloadLink = document.querySelector("#download-link");
|
||||||
const downloadCurrent = document.querySelector("#download-current");
|
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 currentVersionId = null;
|
||||||
let historyVersions = [];
|
let historyVersions = [];
|
||||||
let sourceObjectUrl = null;
|
let sourceObjectUrl = null;
|
||||||
@@ -134,6 +142,15 @@ function resetResult() {
|
|||||||
downloadLink.removeAttribute("download");
|
downloadLink.removeAttribute("download");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearEditorState() {
|
||||||
|
historyVersions = [];
|
||||||
|
currentVersionId = null;
|
||||||
|
clearSourcePreview();
|
||||||
|
resetResult();
|
||||||
|
renderHistory();
|
||||||
|
updateDownloadCurrent(null);
|
||||||
|
}
|
||||||
|
|
||||||
function clearSourcePreview() {
|
function clearSourcePreview() {
|
||||||
if (sourceObjectUrl) {
|
if (sourceObjectUrl) {
|
||||||
URL.revokeObjectURL(sourceObjectUrl);
|
URL.revokeObjectURL(sourceObjectUrl);
|
||||||
@@ -196,7 +213,7 @@ async function selectFile(file) {
|
|||||||
form.append("photo", file, file.name);
|
form.append("photo", file, file.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/history/import", {
|
const response = await apiFetch("/api/history/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
cache: "no-store"
|
cache: "no-store"
|
||||||
@@ -274,6 +291,21 @@ async function readJsonResponse(response) {
|
|||||||
return response.json();
|
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) {
|
async function runJob(kind) {
|
||||||
if (isBusy) {
|
if (isBusy) {
|
||||||
return;
|
return;
|
||||||
@@ -315,7 +347,7 @@ async function runJob(kind) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await apiFetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
cache: "no-store"
|
cache: "no-store"
|
||||||
@@ -476,7 +508,7 @@ function selectHistoryVersion(version) {
|
|||||||
|
|
||||||
async function loadHistoryOnStart() {
|
async function loadHistoryOnStart() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/history", {
|
const response = await apiFetch("/api/history", {
|
||||||
cache: "no-store"
|
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() {
|
async function checkHealth() {
|
||||||
setHealthState("loading", "Проверяем сервер обработки…");
|
setHealthState("loading", "Проверяем сервер обработки…");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/health", {
|
const response = await fetch("/api/health", {
|
||||||
cache: "no-store"
|
cache: "no-store",
|
||||||
|
credentials: "same-origin"
|
||||||
});
|
});
|
||||||
const data = await readJsonResponse(response);
|
const data = await readJsonResponse(response);
|
||||||
|
|
||||||
@@ -601,6 +711,9 @@ promptInput.addEventListener("input", () => {
|
|||||||
editButton.addEventListener("click", () => runJob("edit"));
|
editButton.addEventListener("click", () => runJob("edit"));
|
||||||
upscaleButton.addEventListener("click", () => runJob("upscale"));
|
upscaleButton.addEventListener("click", () => runJob("upscale"));
|
||||||
|
|
||||||
|
loginForm.addEventListener("submit", submitLogin);
|
||||||
|
logoutButton.addEventListener("click", logout);
|
||||||
|
|
||||||
window.addEventListener("beforeunload", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
if (sourceObjectUrl) {
|
if (sourceObjectUrl) {
|
||||||
URL.revokeObjectURL(sourceObjectUrl);
|
URL.revokeObjectURL(sourceObjectUrl);
|
||||||
@@ -608,4 +721,4 @@ window.addEventListener("beforeunload", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
checkHealth();
|
checkHealth();
|
||||||
loadHistoryOnStart();
|
checkAuth();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
rel="icon"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<link rel="stylesheet" href="/style.css?v=8">
|
<link rel="stylesheet" href="/style.css?v=9">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -24,14 +24,19 @@
|
|||||||
<span class="brand-name">Кадр</span>
|
<span class="brand-name">Кадр</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div
|
<div class="topbar-right">
|
||||||
id="health-status"
|
<div
|
||||||
class="health-status health-loading"
|
id="health-status"
|
||||||
role="status"
|
class="health-status health-loading"
|
||||||
aria-live="polite"
|
role="status"
|
||||||
>
|
aria-live="polite"
|
||||||
<span class="health-dot" aria-hidden="true"></span>
|
>
|
||||||
<span id="health-text">Проверяем сервер обработки…</span>
|
<span class="health-dot" aria-hidden="true"></span>
|
||||||
|
<span id="health-text">Проверяем сервер обработки…</span>
|
||||||
|
</div>
|
||||||
|
<button id="logout-button" class="logout-button" type="button" hidden>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -230,12 +235,32 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="auth-overlay" class="auth-overlay" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||||
|
<form id="login-form" class="auth-card" novalidate>
|
||||||
|
<p class="auth-eyebrow">Закрытый доступ</p>
|
||||||
|
<h1 id="auth-title">Вход в редактор</h1>
|
||||||
|
<p class="auth-lead">Редактор доступен только владельцу. Войдите, чтобы продолжить.</p>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="field-label" for="login-username">Логин</label>
|
||||||
|
<input id="login-username" name="username" type="text" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label class="field-label" for="login-password">Пароль</label>
|
||||||
|
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<p id="login-error" class="auth-error" hidden></p>
|
||||||
|
<button id="login-submit" class="button button-primary auth-submit" type="submit">
|
||||||
|
<span>Войти</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<div class="noscript">
|
<div class="noscript">
|
||||||
Для работы редактора необходимо включить JavaScript.
|
Для работы редактора необходимо включить JavaScript.
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<script src="/app.js?v=8" defer></script>
|
<script src="/app.js?v=9" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
120
public/style.css
120
public/style.css
@@ -988,3 +988,123 @@ textarea:disabled {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
144
server.js
144
server.js
@@ -58,6 +58,9 @@ app.use(express.static(path.join(__dirname, "public"), {
|
|||||||
|
|
||||||
let codexEditInFlight = false;
|
let codexEditInFlight = false;
|
||||||
|
|
||||||
|
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
|
||||||
|
app.use(express.json({ limit: "16kb" }));
|
||||||
|
|
||||||
class AppError extends Error {
|
class AppError extends Error {
|
||||||
constructor(status, message, details = undefined) {
|
constructor(status, message, details = undefined) {
|
||||||
super(message);
|
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) {
|
function isHeic(file) {
|
||||||
const mimetype = String(file?.mimetype || "").toLowerCase();
|
const mimetype = String(file?.mimetype || "").toLowerCase();
|
||||||
const extension = path.extname(
|
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(
|
app.post(
|
||||||
"/api/preview",
|
"/api/preview",
|
||||||
upload.single("photo"),
|
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);
|
console.error(error);
|
||||||
message = "Внутренняя ошибка сервера.";
|
message = "Внутренняя ошибка сервера.";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user