Compare commits
10 Commits
a836d3639b
...
4c6f133ff1
| Author | SHA1 | Date |
|---|---|---|
|
|
4c6f133ff1 | |
|
|
e31b247128 | |
|
|
0579976ffc | |
|
|
808d867dd7 | |
|
|
cfee9d0c19 | |
|
|
dc49acaa43 | |
|
|
33ab11ab08 | |
|
|
7a96aaaaf1 | |
|
|
4b4963101a | |
|
|
5004627476 |
|
|
@ -7,5 +7,7 @@ __pycache__/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
infra/.env
|
infra/.env
|
||||||
|
projects/*.secrets.json
|
||||||
|
projects/*.publish.log.jsonl
|
||||||
|
|
||||||
.cms-smoke.png
|
.cms-smoke.png
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -13,13 +13,17 @@
|
||||||
|
|
||||||
Пароль хостинга не хранится в JSON. В конфиге указывается имя переменной окружения, например `MCHOST_PASSWORD`.
|
Пароль хостинга не хранится в JSON. В конфиге указывается имя переменной окружения, например `MCHOST_PASSWORD`.
|
||||||
|
|
||||||
|
CMS редактирует локальный workspace сайта, а внешний хостинг является целью публикации. На Synology текущий NODE.DC workspace монтируется в контейнер как `/sites/nodedc` и физически лежит в `/volume1/docker/dc-cms/sites/nodedc`.
|
||||||
|
|
||||||
|
Если workspace ещё не загружен, админка открывается без вкладок сайта и показывает состояние подключения. После загрузки `content/`, `templates/`, `tools/` и нужных assets появляются режимы администрирования, SEO и Knowledge.
|
||||||
|
|
||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PORT=8210 npm run admin
|
PORT=8210 npm run admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Админка будет доступна на `http://127.0.0.1:8210/admin/`, а подключенный сайт на `http://127.0.0.1:8210/`.
|
Админка будет доступна на `http://127.0.0.1:8210/`. Подключенный сайт хранится как управляемый проект, но не является корневым UI этого сервиса.
|
||||||
|
|
||||||
## Запуск с Authentik
|
## Запуск с Authentik
|
||||||
|
|
||||||
|
|
@ -35,16 +39,17 @@ PORT=8210 npm run admin
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file infra/.env -f infra/docker-compose.yml up -d --build
|
docker compose --env-file infra/.env -f infra/docker-compose.yml up -d --build
|
||||||
infra/scripts/bootstrap-authentik.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Адреса:
|
Адреса:
|
||||||
|
|
||||||
- CMS: `http://cms.local.nodedc:8210/admin/`
|
- CMS: `http://cms.local.nodedc:8210/`
|
||||||
- Authentik login: `http://cms-auth.local.nodedc:8210/`
|
- Authentik login: `http://cms-auth.local.nodedc:8210/`
|
||||||
- Authentik admin: `http://cms-auth-admin.local.nodedc:8210/if/admin/`
|
- Authentik admin: `http://cms-auth-admin.local.nodedc:8210/if/admin/`
|
||||||
|
|
||||||
Bootstrap создает OIDC-приложение `dc-cms`, группы `dc-cms:*` и стартового администратора из `infra/.env`. После первичного входа временный пароль нужно заменить в Authentik.
|
Compose-сервис `authentik-bootstrap` создает OIDC-приложение `dc-cms`, группы `dc-cms:*` и стартового администратора из `infra/.env`. После первичного входа временный пароль нужно заменить в Authentik; следующие deploy не перетрут пароль, пока `CMS_BOOTSTRAP_FORCE_PASSWORD=false`.
|
||||||
|
|
||||||
|
CMS обращается к OIDC discovery внутри compose-сети через host-alias Authentik-домена (`CMS_AUTH_DOMAIN`), а не напрямую в контейнер `authentik-server`. Это сохраняет тот же Host/issuer-контекст, который видит браузер.
|
||||||
|
|
||||||
## Synology
|
## Synology
|
||||||
|
|
||||||
|
|
@ -63,11 +68,4 @@ DNS-записи должны вести на внешний IP NAS. В Synology
|
||||||
|
|
||||||
Оба target-порта ведут в один Caddy контейнер внутри compose-проекта `dc-cms`; Caddy разводит запросы по Host.
|
Оба target-порта ведут в один Caddy контейнер внутри compose-проекта `dc-cms`; Caddy разводит запросы по Host.
|
||||||
|
|
||||||
Bootstrap Authentik on Synology:
|
На Synology bootstrap Authentik выполняется автоматически внутри compose-проекта при `nodedc-deploy apply`. Ручной вход в worker, `VENV_PATH`, отдельный `apply_blueprint` и shell-скрипты не являются штатным сценарием.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /volume1/docker/dc-cms/source/infra
|
|
||||||
sudo env CMS_ENV_FILE=.env.synology sh ./scripts/bootstrap-authentik.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Bootstrap выполняется так же, как в платформе NODE.DC: через `authentik-server ak shell < authentik/bootstrap-cms.py`. Ручной вход в worker, `VENV_PATH` и отдельный `apply_blueprint` не являются штатным сценарием.
|
|
||||||
|
|
|
||||||
180
admin/admin.css
180
admin/admin.css
|
|
@ -368,6 +368,48 @@ body[data-file-picker-active="true"] .editor {
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-card-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-action-button {
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: var(--launcher-radius-circle);
|
||||||
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-action-button svg {
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 0.92rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-action-button--plus {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-action-button:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel-close:hover {
|
.admin-panel-close:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
|
@ -662,8 +704,10 @@ body[data-file-picker-active="true"] .editor {
|
||||||
|
|
||||||
.dirty-state {
|
.dirty-state {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
min-width: 13.5rem;
|
||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: var(--launcher-radius-circle);
|
border-radius: var(--launcher-radius-circle);
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(255, 255, 255, 0.055);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -928,6 +972,67 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-archive-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-archive-control {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.72rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-archive-button {
|
||||||
|
min-width: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-archive-backup-toggle {
|
||||||
|
min-height: 2.72rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-input-wrap input {
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-toggle-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.42rem;
|
||||||
|
display: grid;
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--launcher-radius-circle);
|
||||||
|
background: rgba(255, 255, 255, 0.065);
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-toggle-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-toggle-button svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
.field-label-row {
|
.field-label-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1747,6 +1852,81 @@ select {
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-progress-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-head h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-head strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-track {
|
||||||
|
position: relative;
|
||||||
|
height: 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--launcher-radius-circle);
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-track span {
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
transition: width 180ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-card[data-state="failed"] .publish-progress-track span {
|
||||||
|
background: rgba(255, 120, 120, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-current,
|
||||||
|
.publish-progress-error {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-progress-error {
|
||||||
|
color: #ffd2d2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.publish-progress-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.knowledge-tree {
|
.knowledge-tree {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.28rem;
|
gap: 0.28rem;
|
||||||
|
|
|
||||||
490
admin/admin.js
490
admin/admin.js
|
|
@ -45,6 +45,9 @@ const state = {
|
||||||
page: null,
|
page: null,
|
||||||
projectConfig: null,
|
projectConfig: null,
|
||||||
projectConfigDirty: false,
|
projectConfigDirty: false,
|
||||||
|
keepSiteArchiveBackup: true,
|
||||||
|
publishJob: null,
|
||||||
|
publishResumeAttempted: false,
|
||||||
knowledge: null,
|
knowledge: null,
|
||||||
knowledgeSelectedId: null,
|
knowledgeSelectedId: null,
|
||||||
knowledgeSavedSelection: null,
|
knowledgeSavedSelection: null,
|
||||||
|
|
@ -150,6 +153,7 @@ const el = {
|
||||||
modeContent: document.querySelector("#admin-mode-content"),
|
modeContent: document.querySelector("#admin-mode-content"),
|
||||||
modeSeo: document.querySelector("#admin-mode-seo"),
|
modeSeo: document.querySelector("#admin-mode-seo"),
|
||||||
modeKnowledge: document.querySelector("#admin-mode-knowledge"),
|
modeKnowledge: document.querySelector("#admin-mode-knowledge"),
|
||||||
|
modeNav: document.querySelector(".nodedc-expanded-nav-group"),
|
||||||
sidebarTitle: document.querySelector("#sidebar-title"),
|
sidebarTitle: document.querySelector("#sidebar-title"),
|
||||||
workspaceName: document.querySelector("#workspace-name"),
|
workspaceName: document.querySelector("#workspace-name"),
|
||||||
workspaceRole: document.querySelector("#workspace-role"),
|
workspaceRole: document.querySelector("#workspace-role"),
|
||||||
|
|
@ -158,11 +162,13 @@ const el = {
|
||||||
panelTitle: document.querySelector("#content-panel-title"),
|
panelTitle: document.querySelector("#content-panel-title"),
|
||||||
panelNote: document.querySelector("#content-panel-note"),
|
panelNote: document.querySelector("#content-panel-note"),
|
||||||
regions: document.querySelector("#regions"),
|
regions: document.querySelector("#regions"),
|
||||||
|
addProject: document.querySelector("#add-project"),
|
||||||
projectSettings: document.querySelector("#project-settings"),
|
projectSettings: document.querySelector("#project-settings"),
|
||||||
addBlock: document.querySelector("#add-block"),
|
addBlock: document.querySelector("#add-block"),
|
||||||
reload: document.querySelector("#reload"),
|
reload: document.querySelector("#reload"),
|
||||||
save: document.querySelector("#save"),
|
save: document.querySelector("#save"),
|
||||||
render: document.querySelector("#render"),
|
render: document.querySelector("#render"),
|
||||||
|
publish: document.querySelector("#publish"),
|
||||||
dirtyState: document.querySelector("#dirty-state"),
|
dirtyState: document.querySelector("#dirty-state"),
|
||||||
status: document.querySelector("#status"),
|
status: document.querySelector("#status"),
|
||||||
empty: document.querySelector("#empty-state"),
|
empty: document.querySelector("#empty-state"),
|
||||||
|
|
@ -483,6 +489,19 @@ function setStatus(message) {
|
||||||
el.status.textContent = message;
|
el.status.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readApiJson(response, fallbackError = "Ошибка запроса") {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (response.status === 401 && payload?.loginUrl) {
|
||||||
|
setStatus("Сессия CMS истекла. Открываем повторный вход...");
|
||||||
|
window.location.assign(payload.loginUrl);
|
||||||
|
throw new Error("Требуется повторный вход в CMS");
|
||||||
|
}
|
||||||
|
if (!response.ok || !payload) {
|
||||||
|
throw new Error(payload?.error || fallbackError);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function setDirty(isDirty, message = null) {
|
function setDirty(isDirty, message = null) {
|
||||||
state.dirty = isDirty;
|
state.dirty = isDirty;
|
||||||
el.dirtyState.dataset.dirty = String(isDirty);
|
el.dirtyState.dataset.dirty = String(isDirty);
|
||||||
|
|
@ -582,6 +601,14 @@ function projectRole() {
|
||||||
return state.projectConfig?.role || "Администратор сайта";
|
return state.projectConfig?.role || "Администратор сайта";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function siteStatus() {
|
||||||
|
return state.projectConfig?.site || { ready: false, missingFiles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteReady() {
|
||||||
|
return Boolean(siteStatus().ready);
|
||||||
|
}
|
||||||
|
|
||||||
function applyProjectConfigSurface() {
|
function applyProjectConfigSurface() {
|
||||||
if (el.workspaceName) el.workspaceName.textContent = projectName();
|
if (el.workspaceName) el.workspaceName.textContent = projectName();
|
||||||
if (el.workspaceRole && !isProjectSettingsSelected()) {
|
if (el.workspaceRole && !isProjectSettingsSelected()) {
|
||||||
|
|
@ -592,15 +619,15 @@ function applyProjectConfigSurface() {
|
||||||
|
|
||||||
async function loadProjectConfig() {
|
async function loadProjectConfig() {
|
||||||
const response = await fetch("/api/project/config");
|
const response = await fetch("/api/project/config");
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await readApiJson(response, "Не удалось загрузить настройки проекта");
|
||||||
|
|
||||||
if (!response.ok || !payload) {
|
|
||||||
throw new Error(payload?.error || "Не удалось загрузить настройки проекта");
|
|
||||||
}
|
|
||||||
|
|
||||||
state.projectConfig = payload;
|
state.projectConfig = payload;
|
||||||
state.projectConfigDirty = false;
|
state.projectConfigDirty = false;
|
||||||
applyProjectConfigSurface();
|
applyProjectConfigSurface();
|
||||||
|
if (!state.publishResumeAttempted) {
|
||||||
|
state.publishResumeAttempted = true;
|
||||||
|
resumeActivePublishJob().catch(() => {});
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -630,6 +657,7 @@ function syncProjectConfigFromForm() {
|
||||||
|
|
||||||
async function saveProjectSettings() {
|
async function saveProjectSettings() {
|
||||||
syncProjectConfigFromForm();
|
syncProjectConfigFromForm();
|
||||||
|
|
||||||
const response = await fetch("/api/project/config", {
|
const response = await fetch("/api/project/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|
@ -663,6 +691,151 @@ async function checkProjectConnection() {
|
||||||
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
|
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishProject() {
|
||||||
|
const response = await fetch("/api/project/publish/start", { method: "POST" });
|
||||||
|
const payload = await readApiJson(response, "Не удалось запустить публикацию проекта");
|
||||||
|
if (!payload?.ok || !payload.job?.id) throw new Error(payload?.error || "Сервер не вернул ID публикации");
|
||||||
|
|
||||||
|
state.publishJob = payload.job;
|
||||||
|
updatePublishProgressSurface();
|
||||||
|
await pollPublishJob(payload.job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishJobRunning(job = state.publishJob) {
|
||||||
|
return Boolean(job && !["completed", "failed"].includes(job.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishStateLabel(job) {
|
||||||
|
const labels = {
|
||||||
|
queued: "Публикация поставлена в очередь",
|
||||||
|
scanning: "Считаем файлы и общий объём",
|
||||||
|
connecting: "Подключаемся к хостингу",
|
||||||
|
publishing: "Передаём файлы по SFTP",
|
||||||
|
completed: "Публикация завершена",
|
||||||
|
failed: "Публикация остановлена с ошибкой",
|
||||||
|
};
|
||||||
|
return labels[job?.state] || "Подготавливаем публикацию";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePublishButtonState() {
|
||||||
|
if (!el.publish) return;
|
||||||
|
const running = publishJobRunning();
|
||||||
|
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
|
||||||
|
el.publish.disabled = running || !enabled || !siteReady() || state.projectConfigDirty;
|
||||||
|
el.publish.textContent = running ? `${Number(state.publishJob?.percent || 0).toLocaleString("ru-RU")}%` : "Опубликовать";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPublishProgressPanel() {
|
||||||
|
const panel = document.createElement("section");
|
||||||
|
panel.className = "settings-card publish-progress-card";
|
||||||
|
panel.dataset.publishProgress = "true";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="publish-progress-head">
|
||||||
|
<div>
|
||||||
|
<div class="section-kicker">Публикация</div>
|
||||||
|
<h3 data-publish-title></h3>
|
||||||
|
</div>
|
||||||
|
<strong data-publish-percent></strong>
|
||||||
|
</div>
|
||||||
|
<div class="publish-progress-track" role="progressbar" aria-label="Прогресс публикации" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<span data-publish-bar></span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-progress-stats">
|
||||||
|
<span data-publish-volume></span>
|
||||||
|
<span data-publish-files></span>
|
||||||
|
<span data-publish-uploaded></span>
|
||||||
|
<span data-publish-skipped></span>
|
||||||
|
</div>
|
||||||
|
<p class="publish-progress-current" data-publish-current></p>
|
||||||
|
<p class="publish-progress-error hidden" data-publish-error></p>
|
||||||
|
`;
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePublishProgressSurface() {
|
||||||
|
updatePublishButtonState();
|
||||||
|
const job = state.publishJob;
|
||||||
|
if (!job || !el.projectSettingsForm || el.projectSettingsForm.classList.contains("hidden")) return;
|
||||||
|
|
||||||
|
let panel = el.projectSettingsForm.querySelector("[data-publish-progress]");
|
||||||
|
if (!panel) {
|
||||||
|
panel = createPublishProgressPanel();
|
||||||
|
el.projectSettingsForm.prepend(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = Number(job.percent || 0);
|
||||||
|
panel.dataset.state = job.state || "queued";
|
||||||
|
panel.querySelector("[data-publish-title]").textContent = publishStateLabel(job);
|
||||||
|
panel.querySelector("[data-publish-percent]").textContent = `${percent.toLocaleString("ru-RU")}%`;
|
||||||
|
const track = panel.querySelector(".publish-progress-track");
|
||||||
|
track.setAttribute("aria-valuenow", String(percent));
|
||||||
|
panel.querySelector("[data-publish-bar]").style.width = `${percent}%`;
|
||||||
|
panel.querySelector("[data-publish-volume]").textContent = `${job.processedLabel || "0 Б"} из ${job.totalLabel || "0 Б"}`;
|
||||||
|
panel.querySelector("[data-publish-files]").textContent = `Проверено: ${Number(job.processedFiles || 0).toLocaleString("ru-RU")}/${Number(job.totalFiles || 0).toLocaleString("ru-RU")}`;
|
||||||
|
panel.querySelector("[data-publish-uploaded]").textContent = `Загружено: ${Number(job.uploadedFiles || 0).toLocaleString("ru-RU")}`;
|
||||||
|
panel.querySelector("[data-publish-skipped]").textContent = `Без изменений: ${Number(job.skippedFiles || 0).toLocaleString("ru-RU")}`;
|
||||||
|
panel.querySelector("[data-publish-current]").textContent = job.currentFile ? `Сейчас: ${job.currentFile}` : job.host ? `${job.host}/${job.remoteRoot || ""}` : "";
|
||||||
|
const error = panel.querySelector("[data-publish-error]");
|
||||||
|
error.textContent = job.error || "";
|
||||||
|
error.classList.toggle("hidden", !job.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishProgressStatus(job) {
|
||||||
|
const current = job.currentFile ? ` · ${job.currentFile}` : "";
|
||||||
|
return `${publishStateLabel(job)}: ${Number(job.percent || 0).toLocaleString("ru-RU")}% · ${job.processedLabel || "0 Б"}/${job.totalLabel || "0 Б"}${current}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublishJob(jobId = "") {
|
||||||
|
const query = jobId ? `?jobId=${encodeURIComponent(jobId)}` : "";
|
||||||
|
const response = await fetch(`/api/project/publish/status${query}`);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
const payload = await readApiJson(response, "Не удалось получить состояние публикации");
|
||||||
|
return payload.job || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollPublishJob(jobId) {
|
||||||
|
while (true) {
|
||||||
|
const job = await fetchPublishJob(jobId);
|
||||||
|
if (!job) throw new Error("Сервер потерял состояние публикации");
|
||||||
|
state.publishJob = job;
|
||||||
|
updatePublishProgressSurface();
|
||||||
|
setStatus(publishProgressStatus(job));
|
||||||
|
|
||||||
|
if (job.state === "completed") {
|
||||||
|
setStatus(publishStatusText({ publish: job }).trim() || "Проект опубликован.");
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
if (job.state === "failed") {
|
||||||
|
throw new Error(`Публикация остановлена: ${job.error || "неизвестная ошибка"}. Файл: ${job.currentFile || "не определён"}`);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeActivePublishJob() {
|
||||||
|
const job = await fetchPublishJob();
|
||||||
|
if (!job) return;
|
||||||
|
state.publishJob = job;
|
||||||
|
updatePublishProgressSurface();
|
||||||
|
if (publishJobRunning(job)) {
|
||||||
|
await pollPublishJob(job.id);
|
||||||
|
} else if (job.state === "failed") {
|
||||||
|
setStatus(`Последняя публикация завершилась с ошибкой: ${job.error || "неизвестная ошибка"}. Файл: ${job.currentFile || "не определён"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishStatusText(payload) {
|
||||||
|
const publish = payload?.publish;
|
||||||
|
if (!publish) return "";
|
||||||
|
|
||||||
|
const uploaded = Number(publish.uploadedFiles || 0).toLocaleString("ru-RU");
|
||||||
|
const skipped = Number(publish.skippedFiles || 0).toLocaleString("ru-RU");
|
||||||
|
const total = Number(publish.totalFiles || 0).toLocaleString("ru-RU");
|
||||||
|
const volume = publish.uploadedLabel || "0 МБ";
|
||||||
|
|
||||||
|
return ` Опубликовано на ${publish.host}: загружено ${uploaded}/${total}, пропущено ${skipped}, объем ${volume}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function ensurePageSeo() {
|
function ensurePageSeo() {
|
||||||
if (!state.page) return;
|
if (!state.page) return;
|
||||||
state.page.seo = {
|
state.page.seo = {
|
||||||
|
|
@ -693,6 +866,13 @@ function ensureSeoModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAdminMode(nextMode) {
|
function setAdminMode(nextMode) {
|
||||||
|
if (!siteReady() && !isProjectSettingsSelected()) {
|
||||||
|
state.mode = "content";
|
||||||
|
updateAdminLayoutState({ mode: state.mode });
|
||||||
|
renderSiteUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content";
|
const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content";
|
||||||
state.mode = mode;
|
state.mode = mode;
|
||||||
if (isProjectSettingsSelected()) {
|
if (isProjectSettingsSelected()) {
|
||||||
|
|
@ -714,8 +894,10 @@ function setAdminMode(nextMode) {
|
||||||
function updateAdminModeSurface() {
|
function updateAdminModeSurface() {
|
||||||
const isSeo = state.mode === "seo";
|
const isSeo = state.mode === "seo";
|
||||||
const isKnowledge = state.mode === "knowledge";
|
const isKnowledge = state.mode === "knowledge";
|
||||||
|
const hasSite = siteReady();
|
||||||
document.body.dataset.adminMode = state.mode;
|
document.body.dataset.adminMode = state.mode;
|
||||||
|
|
||||||
|
el.modeNav?.classList.toggle("hidden", !hasSite);
|
||||||
if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge);
|
if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge);
|
||||||
if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo);
|
if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo);
|
||||||
if (el.modeKnowledge) el.modeKnowledge.dataset.active = String(isKnowledge);
|
if (el.modeKnowledge) el.modeKnowledge.dataset.active = String(isKnowledge);
|
||||||
|
|
@ -736,14 +918,28 @@ function updateAdminModeSurface() {
|
||||||
if (el.addBlock) {
|
if (el.addBlock) {
|
||||||
el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок";
|
el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок";
|
||||||
el.addBlock.setAttribute("aria-label", el.addBlock.title);
|
el.addBlock.setAttribute("aria-label", el.addBlock.title);
|
||||||
|
el.addBlock.disabled = !hasSite;
|
||||||
|
}
|
||||||
|
if (el.addProject) {
|
||||||
|
el.addProject.title = "Добавить сайт";
|
||||||
|
el.addProject.setAttribute("aria-label", "Добавить сайт");
|
||||||
}
|
}
|
||||||
if (el.save) {
|
if (el.save) {
|
||||||
el.save.textContent = isKnowledge ? "Сохранить базу" : "Сохранить модель";
|
el.save.textContent = "Сохранить";
|
||||||
el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json";
|
el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json";
|
||||||
|
el.save.disabled = !hasSite && !isProjectSettingsSelected();
|
||||||
}
|
}
|
||||||
if (el.render) {
|
if (el.render) {
|
||||||
el.render.textContent = isKnowledge ? "Обновить /knowledge/" : "Обновить index.html";
|
el.render.textContent = "Опубликовать";
|
||||||
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
|
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
|
||||||
|
el.render.disabled = !hasSite && !isProjectSettingsSelected();
|
||||||
|
}
|
||||||
|
if (el.publish) {
|
||||||
|
el.publish.classList.add("hidden");
|
||||||
|
el.publish.disabled = true;
|
||||||
|
}
|
||||||
|
if (el.reload) {
|
||||||
|
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1796,7 +1992,7 @@ function openTemplateModal() {
|
||||||
|
|
||||||
function closeTemplateModal() {
|
function closeTemplateModal() {
|
||||||
el.templateModal.classList.add("hidden");
|
el.templateModal.classList.add("hidden");
|
||||||
el.addBlock.focus();
|
el.addBlock?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDeleteModal(confirmed = false) {
|
function closeDeleteModal(confirmed = false) {
|
||||||
|
|
@ -1903,6 +2099,7 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
const control = options.length ? document.createElement("select") : document.createElement("input");
|
const control = options.length ? document.createElement("select") : document.createElement("input");
|
||||||
const value = projectConfigValue(path);
|
const value = projectConfigValue(path);
|
||||||
|
const isSecret = type === "password";
|
||||||
|
|
||||||
field.className = type === "checkbox" ? "toggle-row" : "field-control";
|
field.className = type === "checkbox" ? "toggle-row" : "field-control";
|
||||||
title.textContent = label;
|
title.textContent = label;
|
||||||
|
|
@ -1927,19 +2124,41 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
|
||||||
}
|
}
|
||||||
|
|
||||||
badge.className = "field-kind";
|
badge.className = "field-kind";
|
||||||
badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : "TEXT";
|
badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : isSecret ? "SECRET" : "TEXT";
|
||||||
field.append(title, badge, control);
|
field.append(title, badge);
|
||||||
|
|
||||||
|
if (isSecret) {
|
||||||
|
const secretWrap = document.createElement("span");
|
||||||
|
const reveal = document.createElement("button");
|
||||||
|
secretWrap.className = "secret-input-wrap";
|
||||||
|
reveal.className = "secret-toggle-button";
|
||||||
|
reveal.type = "button";
|
||||||
|
reveal.innerHTML =
|
||||||
|
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.75 12s3.25-5.5 9.25-5.5 9.25 5.5 9.25 5.5-3.25 5.5-9.25 5.5S2.75 12 2.75 12Z"></path><path d="M12 9.25a2.75 2.75 0 1 1 0 5.5 2.75 2.75 0 0 1 0-5.5Z"></path></svg>';
|
||||||
|
reveal.setAttribute("aria-label", "Показать значение");
|
||||||
|
reveal.title = "Показать значение";
|
||||||
|
reveal.addEventListener("click", () => {
|
||||||
|
const visible = control.type === "text";
|
||||||
|
control.type = visible ? "password" : "text";
|
||||||
|
reveal.setAttribute("aria-label", visible ? "Показать значение" : "Скрыть значение");
|
||||||
|
reveal.title = visible ? "Показать значение" : "Скрыть значение";
|
||||||
|
});
|
||||||
|
secretWrap.append(control, reveal);
|
||||||
|
field.append(secretWrap);
|
||||||
|
} else {
|
||||||
|
field.append(control);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
control.dataset.projectPath = path;
|
control.dataset.projectPath = path;
|
||||||
control.addEventListener("input", () => {
|
const onProjectFieldChange = () => {
|
||||||
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
||||||
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
||||||
});
|
if (path === "deploy.enabled") renderProjectSettings();
|
||||||
control.addEventListener("change", () => {
|
};
|
||||||
setProjectConfigValue(path, normalizeProjectInputValue(control));
|
|
||||||
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
|
control.addEventListener("input", onProjectFieldChange);
|
||||||
});
|
control.addEventListener("change", onProjectFieldChange);
|
||||||
|
|
||||||
if (hint && type !== "checkbox") {
|
if (hint && type !== "checkbox") {
|
||||||
const note = document.createElement("small");
|
const note = document.createElement("small");
|
||||||
|
|
@ -1988,6 +2207,105 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadSiteArchive(file, keepBackup = true) {
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, file.name);
|
||||||
|
|
||||||
|
const payload = await new Promise((resolve, reject) => {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.open("POST", `/api/project/upload-site?backup=${keepBackup ? "1" : "0"}`);
|
||||||
|
request.responseType = "json";
|
||||||
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
if (!event.lengthComputable) {
|
||||||
|
setStatus(`Загружаем архив сайта: ${file.name}...`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const percent = Math.round((event.loaded / event.total) * 1000) / 10;
|
||||||
|
setStatus(`Загружаем архив сайта: ${percent.toLocaleString("ru-RU")}% · ${file.name}`);
|
||||||
|
});
|
||||||
|
request.upload.addEventListener("load", () => {
|
||||||
|
setStatus(`Архив ${file.name} передан. Проверяем и распаковываем на сервере...`);
|
||||||
|
});
|
||||||
|
request.addEventListener("load", () => {
|
||||||
|
const responsePayload = request.response && typeof request.response === "object"
|
||||||
|
? request.response
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(request.responseText || "null");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (request.status === 401 && responsePayload?.loginUrl) {
|
||||||
|
window.location.assign(responsePayload.loginUrl);
|
||||||
|
reject(new Error("Требуется повторный вход в CMS"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.status < 200 || request.status >= 300 || !responsePayload?.ok) {
|
||||||
|
reject(new Error(responsePayload?.error || "Не удалось загрузить архив сайта"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(responsePayload);
|
||||||
|
});
|
||||||
|
request.addEventListener("error", () => reject(new Error("Соединение оборвалось во время загрузки архива")));
|
||||||
|
request.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.projectConfig = await loadProjectConfig();
|
||||||
|
renderProjectSettings();
|
||||||
|
const backupStatus = payload.backup?.saved
|
||||||
|
? ` Бэкап сохранён: ${payload.backup.fileName} (${payload.backup.label}).`
|
||||||
|
: " Бэкап не создавался.";
|
||||||
|
setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.${backupStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectArchiveUpload() {
|
||||||
|
const field = document.createElement("div");
|
||||||
|
const title = document.createElement("span");
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const body = document.createElement("div");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
const backupToggle = document.createElement("label");
|
||||||
|
const backupCheckbox = document.createElement("input");
|
||||||
|
const backupLabel = document.createElement("span");
|
||||||
|
const note = document.createElement("small");
|
||||||
|
|
||||||
|
field.className = "field-control project-archive-field";
|
||||||
|
title.textContent = "Архив сайта";
|
||||||
|
badge.className = "field-kind";
|
||||||
|
badge.textContent = "ZIP";
|
||||||
|
body.className = "project-archive-control";
|
||||||
|
button.className = "ghost-btn project-archive-button";
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Загрузить архив сайта";
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = ".zip,application/zip";
|
||||||
|
input.className = "hidden";
|
||||||
|
backupToggle.className = "toggle-row project-archive-backup-toggle";
|
||||||
|
backupCheckbox.type = "checkbox";
|
||||||
|
backupCheckbox.checked = state.keepSiteArchiveBackup;
|
||||||
|
backupLabel.textContent = "Сохранить бэкап на сервере";
|
||||||
|
backupToggle.append(backupCheckbox, backupLabel);
|
||||||
|
note.className = "field-hint";
|
||||||
|
note.textContent = "При включённом бэкапе исходный ZIP сохраняется отдельной версией в .cms-backups. ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера.";
|
||||||
|
|
||||||
|
button.addEventListener("click", () => input.click());
|
||||||
|
backupCheckbox.addEventListener("change", () => {
|
||||||
|
state.keepSiteArchiveBackup = backupCheckbox.checked;
|
||||||
|
});
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = "";
|
||||||
|
uploadSiteArchive(file, backupCheckbox.checked).catch((error) => setStatus(error.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
body.append(button, input, backupToggle);
|
||||||
|
field.append(title, badge, body, note);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
function renderProjectRuntimeLine(label, value) {
|
function renderProjectRuntimeLine(label, value) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
const code = document.createElement("code");
|
const code = document.createElement("code");
|
||||||
|
|
@ -1996,6 +2314,43 @@ function renderProjectRuntimeLine(label, value) {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEmptyState({ icon = "ND", title, body, stateName = "" }) {
|
||||||
|
el.empty.dataset.state = stateName;
|
||||||
|
el.empty.innerHTML = `
|
||||||
|
<div class="empty-icon">${escapeHtml(icon)}</div>
|
||||||
|
<div>
|
||||||
|
<h3>${escapeHtml(title)}</h3>
|
||||||
|
<p>${escapeHtml(body)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSiteUnavailable() {
|
||||||
|
updateAdminModeSurface();
|
||||||
|
applyProjectConfigSurface();
|
||||||
|
el.regions.innerHTML = "";
|
||||||
|
el.form.classList.add("hidden");
|
||||||
|
el.knowledgeForm.classList.add("hidden");
|
||||||
|
el.projectSettingsForm.classList.add("hidden");
|
||||||
|
el.empty.classList.remove("hidden");
|
||||||
|
|
||||||
|
const missing = siteStatus().missingFiles || [];
|
||||||
|
const missingText = missing.length ? ` Сейчас отсутствуют: ${missing.join(", ")}.` : "";
|
||||||
|
const root = siteStatus().root || state.projectConfig?.runtime?.siteRoot || "";
|
||||||
|
const rootText = root ? ` Локальная папка: ${root}.` : "";
|
||||||
|
|
||||||
|
renderEmptyState({
|
||||||
|
icon: "DC",
|
||||||
|
title: "Сайт недоступен",
|
||||||
|
body: `Проверьте настройки подключения к хостингу или загрузите архив сайта в локальный workspace CMS.${missingText}${rootText}`,
|
||||||
|
stateName: "site-unavailable",
|
||||||
|
});
|
||||||
|
if (el.editorHeading) el.editorHeading.textContent = "DC CMS";
|
||||||
|
el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`;
|
||||||
|
el.selectedTitle.textContent = "Подключите сайт";
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
|
||||||
function renderProjectSettings() {
|
function renderProjectSettings() {
|
||||||
updateAdminModeSurface();
|
updateAdminModeSurface();
|
||||||
el.empty.classList.add("hidden");
|
el.empty.classList.add("hidden");
|
||||||
|
|
@ -2022,33 +2377,48 @@ function renderProjectSettings() {
|
||||||
el.render.textContent = "Проверить проект";
|
el.render.textContent = "Проверить проект";
|
||||||
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
|
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
|
||||||
}
|
}
|
||||||
|
if (el.publish) {
|
||||||
|
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
|
||||||
|
el.publish.classList.toggle("hidden", !enabled);
|
||||||
|
el.publish.disabled = !enabled || !siteReady() || state.projectConfigDirty;
|
||||||
|
el.publish.title = state.projectConfigDirty
|
||||||
|
? "Сначала сохраните настройки проекта"
|
||||||
|
: enabled
|
||||||
|
? "Опубликовать текущую копию сайта на хостинг"
|
||||||
|
: "Включите деплой в настройках проекта";
|
||||||
|
}
|
||||||
|
|
||||||
const projectCard = createProjectSettingsCard({
|
const projectCard = createProjectSettingsCard({
|
||||||
kicker: "Проект",
|
kicker: "Проект",
|
||||||
title: "Подключенный сайт",
|
title: "Подключенный сайт",
|
||||||
note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.",
|
note: "Проект хранится в workspace CMS. Архив сайта загружается один раз и дальше редактируется через админку.",
|
||||||
fields: [
|
fields: [
|
||||||
createProjectField({ path: "name", label: "Название проекта" }),
|
createProjectField({ path: "name", label: "Название проекта" }),
|
||||||
createProjectField({ path: "role", label: "Роль в сайдбаре" }),
|
|
||||||
createProjectField({ path: "siteRoot", label: "Локальный путь сайта", placeholder: "../NODEDC_SITE" }),
|
|
||||||
createProjectField({ path: "previewUrl", label: "Preview URL", placeholder: "http://127.0.0.1:8210/" }),
|
|
||||||
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
|
createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }),
|
||||||
|
renderProjectArchiveUpload(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const deployCard = createProjectSettingsCard({
|
const deployCard = createProjectSettingsCard({
|
||||||
kicker: "Хостинг",
|
kicker: "Публикация",
|
||||||
title: "Публикация на McHost / Synology",
|
title: "Деплой на хостинг",
|
||||||
note: "Пароль не пишем в JSON. В поле passwordEnv указывается имя переменной окружения сервиса, где будет лежать пароль.",
|
note: "Укажите доступ к хостингу. Пароль сохраняется на сервере в зашифрованном виде и не возвращается в браузер.",
|
||||||
fields: [
|
fields: [
|
||||||
createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }),
|
createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }),
|
||||||
createProjectField({ path: "deploy.deployOnRender", label: "Деплоить после рендера", type: "checkbox" }),
|
|
||||||
createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }),
|
createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }),
|
||||||
createProjectField({ path: "deploy.host", label: "Хост", placeholder: "server.mchost.ru" }),
|
createProjectField({ path: "deploy.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.mchost.ru" }),
|
||||||
createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }),
|
createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }),
|
||||||
createProjectField({ path: "deploy.username", label: "Логин" }),
|
createProjectField({ path: "deploy.username", label: "Логин" }),
|
||||||
createProjectField({ path: "deploy.passwordEnv", label: "Переменная пароля", placeholder: "MCHOST_PASSWORD" }),
|
createProjectField({
|
||||||
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "/public_html/nodedc.ru" }),
|
path: "deploy.password",
|
||||||
|
label: "Пароль",
|
||||||
|
type: "password",
|
||||||
|
placeholder: state.projectConfig?.deploy?.passwordConfigured ? "Пароль сохранен. Введите новый для замены" : "Введите пароль",
|
||||||
|
hint: state.projectConfig?.deploy?.passwordConfigured
|
||||||
|
? "Пароль уже сохранен на сервере. Поле пустое, потому что секрет не возвращается в браузер."
|
||||||
|
: "Введите пароль один раз и нажмите «Сохранить настройки».",
|
||||||
|
}),
|
||||||
|
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2066,6 +2436,7 @@ function renderProjectSettings() {
|
||||||
});
|
});
|
||||||
|
|
||||||
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
|
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
|
||||||
|
updatePublishProgressSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
|
|
@ -2081,6 +2452,11 @@ function renderEditor() {
|
||||||
if (!block) {
|
if (!block) {
|
||||||
el.empty.classList.remove("hidden");
|
el.empty.classList.remove("hidden");
|
||||||
el.form.classList.add("hidden");
|
el.form.classList.add("hidden");
|
||||||
|
renderEmptyState({
|
||||||
|
icon: "ND",
|
||||||
|
title: "Выберите блок слева",
|
||||||
|
body: "Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.",
|
||||||
|
});
|
||||||
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
|
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
|
||||||
el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой";
|
el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой";
|
||||||
renderLandingTargetOptions();
|
renderLandingTargetOptions();
|
||||||
|
|
@ -2124,6 +2500,11 @@ function renderEditor() {
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
try {
|
try {
|
||||||
|
if (!siteReady() && !isProjectSettingsSelected()) {
|
||||||
|
renderSiteUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isProjectSettingsSelected()) {
|
if (isProjectSettingsSelected()) {
|
||||||
if (state.mode === "knowledge" && state.knowledge) {
|
if (state.mode === "knowledge" && state.knowledge) {
|
||||||
ensureKnowledgeSelection();
|
ensureKnowledgeSelection();
|
||||||
|
|
@ -5205,6 +5586,11 @@ function renderKnowledgeEditor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.knowledge.settings ||= {};
|
state.knowledge.settings ||= {};
|
||||||
|
state.knowledge.seo ||= {};
|
||||||
|
if (!state.knowledge.seo.title) state.knowledge.seo.title = state.knowledge.title || "Knowledge";
|
||||||
|
if (!state.knowledge.seo.description && state.knowledge.settings.description) {
|
||||||
|
state.knowledge.seo.description = state.knowledge.settings.description;
|
||||||
|
}
|
||||||
const bodyPath = isRoot ? "introHtml" : "bodyHtml";
|
const bodyPath = isRoot ? "introHtml" : "bodyHtml";
|
||||||
const match = isRoot ? null : findKnowledgeNode(node.id);
|
const match = isRoot ? null : findKnowledgeNode(node.id);
|
||||||
const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний";
|
const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний";
|
||||||
|
|
@ -5253,7 +5639,8 @@ function renderKnowledgeEditor() {
|
||||||
seoGrid.className = "group-grid";
|
seoGrid.className = "group-grid";
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
seoGrid.append(
|
seoGrid.append(
|
||||||
createKnowledgeInput({ node, path: "settings.description", label: "SEO description / описание", kind: "textarea", wide: true }),
|
createKnowledgeInput({ node, path: "seo.title", label: "SEO title", wide: true }),
|
||||||
|
createKnowledgeInput({ node, path: "seo.description", label: "SEO description / описание", kind: "textarea", wide: true }),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
node.seo ||= {};
|
node.seo ||= {};
|
||||||
|
|
@ -5327,9 +5714,13 @@ function renderKnowledgeAll() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKnowledge() {
|
async function loadKnowledge() {
|
||||||
|
if (!siteReady()) {
|
||||||
|
renderSiteUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/knowledge");
|
const response = await fetch("/api/knowledge");
|
||||||
if (!response.ok) throw new Error(await response.text());
|
state.knowledge = await readApiJson(response, "Не удалось загрузить базу знаний");
|
||||||
state.knowledge = await response.json();
|
|
||||||
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
||||||
ensureKnowledgeSelection();
|
ensureKnowledgeSelection();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
@ -5363,22 +5754,28 @@ async function renderKnowledge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана.");
|
setStatus(
|
||||||
|
`${payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."}${publishStatusText(payload)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
const [projectConfig, pageResponse, templatesResponse] = await Promise.all([
|
const projectConfig = await loadProjectConfig();
|
||||||
loadProjectConfig(),
|
|
||||||
fetch("/api/page/home"),
|
|
||||||
fetch("/api/block-templates/home"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
state.projectConfig = projectConfig;
|
state.projectConfig = projectConfig;
|
||||||
if (!pageResponse.ok) throw new Error(await pageResponse.text());
|
state.knowledge = null;
|
||||||
if (!templatesResponse.ok) throw new Error(await templatesResponse.text());
|
|
||||||
|
|
||||||
state.page = await pageResponse.json();
|
if (!siteReady()) {
|
||||||
state.templates = await templatesResponse.json();
|
state.page = null;
|
||||||
|
state.templates = [];
|
||||||
|
state.selected = null;
|
||||||
|
renderSiteUnavailable();
|
||||||
|
setStatus("Сайт не загружен в локальный workspace CMS.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]);
|
||||||
|
state.page = await readApiJson(pageResponse, "Не удалось загрузить home.json");
|
||||||
|
state.templates = await readApiJson(templatesResponse, "Не удалось загрузить шаблоны блоков");
|
||||||
ensureStaticElements();
|
ensureStaticElements();
|
||||||
ensureSectionsRegion();
|
ensureSectionsRegion();
|
||||||
ensureSeoModel();
|
ensureSeoModel();
|
||||||
|
|
@ -5421,7 +5818,9 @@ async function renderHome() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
setStatus(
|
||||||
|
`${payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."}${publishStatusText(payload)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSelected(delta) {
|
function moveSelected(delta) {
|
||||||
|
|
@ -5514,6 +5913,7 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
||||||
el.modeContent?.addEventListener("click", () => setAdminMode("content"));
|
el.modeContent?.addEventListener("click", () => setAdminMode("content"));
|
||||||
el.modeSeo?.addEventListener("click", () => setAdminMode("seo"));
|
el.modeSeo?.addEventListener("click", () => setAdminMode("seo"));
|
||||||
el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge"));
|
el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge"));
|
||||||
|
el.addProject?.addEventListener("click", openProjectSettings);
|
||||||
el.projectSettings?.addEventListener("click", openProjectSettings);
|
el.projectSettings?.addEventListener("click", openProjectSettings);
|
||||||
|
|
||||||
el.json.addEventListener("blur", () => {
|
el.json.addEventListener("blur", () => {
|
||||||
|
|
@ -5525,7 +5925,7 @@ el.json.addEventListener("blur", () => {
|
||||||
|
|
||||||
syncBlockFromJson();
|
syncBlockFromJson();
|
||||||
});
|
});
|
||||||
el.addBlock.addEventListener("click", () => {
|
el.addBlock?.addEventListener("click", () => {
|
||||||
if (state.mode === "knowledge") {
|
if (state.mode === "knowledge") {
|
||||||
addKnowledgeNode(null);
|
addKnowledgeNode(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -5730,6 +6130,10 @@ el.render.addEventListener("click", () => {
|
||||||
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
|
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
|
||||||
action().catch((error) => setStatus(error.message));
|
action().catch((error) => setStatus(error.message));
|
||||||
});
|
});
|
||||||
|
el.publish?.addEventListener("click", () => {
|
||||||
|
if (!isProjectSettingsSelected()) return;
|
||||||
|
publishProject().catch((error) => setStatus(error.message));
|
||||||
|
});
|
||||||
el.moveUp.addEventListener("click", () => moveSelected(-1));
|
el.moveUp.addEventListener("click", () => moveSelected(-1));
|
||||||
el.moveDown.addEventListener("click", () => moveSelected(1));
|
el.moveDown.addEventListener("click", () => moveSelected(1));
|
||||||
el.duplicate.addEventListener("click", duplicateSelected);
|
el.duplicate.addEventListener("click", duplicateSelected);
|
||||||
|
|
|
||||||
|
|
@ -55,19 +55,22 @@
|
||||||
<div class="section-kicker">NODE.DC</div>
|
<div class="section-kicker">NODE.DC</div>
|
||||||
<h1 id="sidebar-title">Администрирование</h1>
|
<h1 id="sidebar-title">Администрирование</h1>
|
||||||
</div>
|
</div>
|
||||||
<button id="project-settings" class="admin-panel-close project-settings-button" type="button" aria-label="Настройки проекта" title="Настройки проекта">
|
<button id="add-project" class="admin-panel-close add-block-button" type="button" aria-label="Добавить сайт" title="Добавить сайт">+</button>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
||||||
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z" />
|
|
||||||
<path d="M19.4 13.5c.06-.48.06-.98 0-1.5l2-1.55-1.9-3.3-2.35.95a8.2 8.2 0 0 0-1.3-.75L15.5 4.8h-3.8l-.35 2.55c-.46.2-.9.45-1.3.75L7.7 7.15l-1.9 3.3L7.8 12a8.5 8.5 0 0 0 0 1.5l-2 1.55 1.9 3.3 2.35-.95c.4.3.84.55 1.3.75l.35 2.55h3.8l.35-2.55c.46-.2.9-.45 1.3-.75l2.35.95 1.9-3.3-2-1.55Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
|
|
||||||
<div class="workspace-card admin-panel-client-select">
|
<div class="workspace-card admin-panel-client-select">
|
||||||
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
||||||
<div>
|
<div>
|
||||||
<div id="workspace-name" class="workspace-name">NODE.DC</div>
|
<div id="workspace-name" class="workspace-name">NODE.DC</div>
|
||||||
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
|
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="workspace-card-actions">
|
||||||
|
<button id="project-settings" class="workspace-action-button" type="button" aria-label="Настройки проекта" title="Настройки проекта">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z" />
|
||||||
|
<path d="M19.4 13.5c.06-.48.06-.98 0-1.5l2-1.55-1.9-3.3-2.35.95a8.2 8.2 0 0 0-1.3-.75L15.5 4.8h-3.8l-.35 2.55c-.46.2-.9.45-1.3.75L7.7 7.15l-1.9 3.3L7.8 12a8.5 8.5 0 0 0 0 1.5l-2 1.55 1.9 3.3 2.35-.95c.4.3.84.55 1.3.75l.35 2.55h3.8l.35-2.55c.46-.2.9-.45 1.3-.75l2.35.95 1.9-3.3-2-1.55Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="add-block" class="workspace-action-button workspace-action-button--plus" type="button" aria-label="Добавить блок" title="Добавить блок">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="regions" class="regions"></div>
|
<div id="regions" class="regions"></div>
|
||||||
|
|
@ -85,6 +88,7 @@
|
||||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
||||||
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||||
|
<button id="publish" class="ghost-btn hidden" type="button" title="Опубликовать сайт на хостинг">Опубликовать</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,18 +30,16 @@ AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
|
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
|
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
|
||||||
|
|
||||||
# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh.
|
# CMS bootstrap user. Applied automatically by the authentik-bootstrap compose service.
|
||||||
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
|
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
|
||||||
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
|
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
|
||||||
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
|
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
|
||||||
|
CMS_BOOTSTRAP_FORCE_PASSWORD=false
|
||||||
|
|
||||||
# CMS OIDC application. bootstrap-authentik.sh creates the matching provider.
|
# CMS OIDC application. The authentik-bootstrap compose service creates the matching provider.
|
||||||
CMS_AUTH_ENABLED=true
|
CMS_AUTH_ENABLED=true
|
||||||
CMS_BASE_URL=http://cms.local.nodedc:8210
|
CMS_BASE_URL=http://cms.local.nodedc:8210
|
||||||
CMS_OIDC_ISSUER=http://cms-auth.local.nodedc:8210/application/o/dc-cms/
|
CMS_OIDC_ISSUER=http://cms-auth.local.nodedc:8210/application/o/dc-cms/
|
||||||
CMS_OIDC_INTERNAL_BASE_URL=http://reverse-proxy
|
|
||||||
CMS_OIDC_INTERNAL_HOST=cms-auth.local.nodedc
|
|
||||||
CMS_OIDC_INTERNAL_PROTO=http
|
|
||||||
CMS_OIDC_CLIENT_ID=dc-cms
|
CMS_OIDC_CLIENT_ID=dc-cms
|
||||||
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
|
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
|
||||||
CMS_OIDC_REDIRECT_URI=http://cms.local.nodedc:8210/auth/callback
|
CMS_OIDC_REDIRECT_URI=http://cms.local.nodedc:8210/auth/callback
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,16 @@ AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
|
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
|
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
|
||||||
|
|
||||||
# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh.
|
# CMS bootstrap user. Applied automatically by the authentik-bootstrap compose service.
|
||||||
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
|
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
|
||||||
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
|
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
|
||||||
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
|
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
|
||||||
|
CMS_BOOTSTRAP_FORCE_PASSWORD=false
|
||||||
|
|
||||||
# CMS OIDC application. bootstrap-authentik.sh creates the matching provider.
|
# CMS OIDC application. The authentik-bootstrap compose service creates the matching provider.
|
||||||
CMS_AUTH_ENABLED=true
|
CMS_AUTH_ENABLED=true
|
||||||
CMS_BASE_URL=https://cms.dcserve.ru
|
CMS_BASE_URL=https://cms.dcserve.ru
|
||||||
CMS_OIDC_ISSUER=https://auth.dcserve.ru/application/o/dc-cms/
|
CMS_OIDC_ISSUER=https://auth.dcserve.ru/application/o/dc-cms/
|
||||||
CMS_OIDC_INTERNAL_BASE_URL=http://reverse-proxy
|
|
||||||
CMS_OIDC_INTERNAL_HOST=auth.dcserve.ru
|
|
||||||
CMS_OIDC_INTERNAL_PROTO=https
|
|
||||||
CMS_OIDC_CLIENT_ID=dc-cms
|
CMS_OIDC_CLIENT_ID=dc-cms
|
||||||
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
|
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
|
||||||
CMS_OIDC_REDIRECT_URI=https://cms.dcserve.ru/auth/callback
|
CMS_OIDC_REDIRECT_URI=https://cms.dcserve.ru/auth/callback
|
||||||
|
|
@ -52,5 +50,6 @@ CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher
|
||||||
|
|
||||||
# CMS app session
|
# CMS app session
|
||||||
CMS_SESSION_SECRET=replace-with-random-secret
|
CMS_SESSION_SECRET=replace-with-random-secret
|
||||||
|
CMS_SECRETS_KEY=replace-with-random-secret
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,13 @@ def optional_env(name, default=""):
|
||||||
return environ.get(name, default).strip()
|
return environ.get(name, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def bool_env(name, default=False):
|
||||||
|
value = environ.get(name, "")
|
||||||
|
if value == "":
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def ensure_group(name, is_superuser=False):
|
def ensure_group(name, is_superuser=False):
|
||||||
group, _ = Group.objects.get_or_create(name=name)
|
group, _ = Group.objects.get_or_create(name=name)
|
||||||
group.is_superuser = is_superuser
|
group.is_superuser = is_superuser
|
||||||
|
|
@ -88,6 +95,7 @@ def ensure_admin_user(groups):
|
||||||
user = User.objects.filter(email__iexact=admin_email).first() or User.objects.filter(
|
user = User.objects.filter(email__iexact=admin_email).first() or User.objects.filter(
|
||||||
username=admin_email
|
username=admin_email
|
||||||
).first()
|
).first()
|
||||||
|
created = user is None
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(username=admin_email, email=admin_email, name=admin_email, type="internal")
|
user = User(username=admin_email, email=admin_email, name=admin_email, type="internal")
|
||||||
|
|
||||||
|
|
@ -101,7 +109,7 @@ def ensure_admin_user(groups):
|
||||||
environ.get("CMS_BOOTSTRAP_ADMIN_PASSWORD", "")
|
environ.get("CMS_BOOTSTRAP_ADMIN_PASSWORD", "")
|
||||||
or environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD", "")
|
or environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD", "")
|
||||||
)
|
)
|
||||||
if admin_password:
|
if admin_password and (created or bool_env("CMS_BOOTSTRAP_FORCE_PASSWORD")):
|
||||||
user.set_password(admin_password)
|
user.set_password(admin_password)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,7 @@ services:
|
||||||
SITE_ROOT: /sites/nodedc
|
SITE_ROOT: /sites/nodedc
|
||||||
CMS_AUTH_ENABLED: ${CMS_AUTH_ENABLED:-true}
|
CMS_AUTH_ENABLED: ${CMS_AUTH_ENABLED:-true}
|
||||||
CMS_OIDC_ISSUER: ${CMS_OIDC_ISSUER:?cms oidc issuer required}
|
CMS_OIDC_ISSUER: ${CMS_OIDC_ISSUER:?cms oidc issuer required}
|
||||||
CMS_OIDC_INTERNAL_BASE_URL: ${CMS_OIDC_INTERNAL_BASE_URL:-}
|
CMS_OIDC_INTERNAL_BASE_URL: http://${CMS_AUTH_DOMAIN:-cms-auth.local.nodedc}
|
||||||
CMS_OIDC_INTERNAL_HOST: ${CMS_OIDC_INTERNAL_HOST:-}
|
|
||||||
CMS_OIDC_INTERNAL_PROTO: ${CMS_OIDC_INTERNAL_PROTO:-}
|
|
||||||
CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required}
|
CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required}
|
||||||
CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required}
|
CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required}
|
||||||
CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required}
|
CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required}
|
||||||
|
|
@ -55,8 +53,8 @@ services:
|
||||||
- ../projects:/app/projects
|
- ../projects:/app/projects
|
||||||
- ${CMS_NODEDC_SITE_PATH:-../../NODEDC_SITE}:/sites/nodedc
|
- ${CMS_NODEDC_SITE_PATH:-../../NODEDC_SITE}:/sites/nodedc
|
||||||
depends_on:
|
depends_on:
|
||||||
authentik-server:
|
authentik-bootstrap:
|
||||||
condition: service_started
|
condition: service_completed_successfully
|
||||||
networks:
|
networks:
|
||||||
- cms-edge
|
- cms-edge
|
||||||
|
|
||||||
|
|
@ -134,6 +132,46 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- cms-edge
|
- cms-edge
|
||||||
|
|
||||||
|
authentik-bootstrap:
|
||||||
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
|
||||||
|
command:
|
||||||
|
- shell
|
||||||
|
- -c
|
||||||
|
- exec(open('/bootstrap/bootstrap-cms.py', encoding='utf-8').read())
|
||||||
|
restart: "no"
|
||||||
|
env_file:
|
||||||
|
- ${CMS_SERVICE_ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
|
||||||
|
CMS_AUTH_DOMAIN: ${CMS_AUTH_DOMAIN:-cms-auth.local.nodedc}
|
||||||
|
CMS_BOOTSTRAP_ADMIN_EMAIL: ${CMS_BOOTSTRAP_ADMIN_EMAIL:-}
|
||||||
|
CMS_BOOTSTRAP_ADMIN_PASSWORD: ${CMS_BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
CMS_BOOTSTRAP_ADMIN_NAME: ${CMS_BOOTSTRAP_ADMIN_NAME:-DCTOUCH CMS Admin}
|
||||||
|
CMS_BOOTSTRAP_FORCE_PASSWORD: ${CMS_BOOTSTRAP_FORCE_PASSWORD:-false}
|
||||||
|
CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required}
|
||||||
|
CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required}
|
||||||
|
CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required}
|
||||||
|
CMS_OIDC_LOGGED_OUT_REDIRECT_URI: ${CMS_OIDC_LOGGED_OUT_REDIRECT_URI:?cms oidc logged out redirect uri required}
|
||||||
|
CMS_BASE_URL: ${CMS_BASE_URL:?cms base url required}
|
||||||
|
CMS_LOGOUT_URI: ${CMS_LOGOUT_URI:-}
|
||||||
|
depends_on:
|
||||||
|
authentik-server:
|
||||||
|
condition: service_healthy
|
||||||
|
authentik-worker:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- authentik-data:/data
|
||||||
|
- authentik-certs:/certs
|
||||||
|
- ./authentik/custom-templates:/templates:ro
|
||||||
|
- ./authentik/bootstrap-cms.py:/bootstrap/bootstrap-cms.py:ro
|
||||||
|
networks:
|
||||||
|
- cms-edge
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cms-edge:
|
cms-edge:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,88 @@
|
||||||
"name": "dc-cms",
|
"name": "dc-cms",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^6.2.3"
|
"adm-zip": "^0.5.18",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"ssh2-sftp-client": "^12.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
|
||||||
|
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
|
|
@ -19,6 +98,114 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.10",
|
||||||
|
"nan": "^2.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ssh2-sftp-client": {
|
||||||
|
"version": "12.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz",
|
||||||
|
"integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"ssh2": "^1.16.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.20.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://square.link/u/4g7sPflL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"start": "node server/admin-server.mjs"
|
"start": "node server/admin-server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^6.2.3"
|
"adm-zip": "^0.5.18",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"ssh2-sftp-client": "^12.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue