From 45b8317707cab74c2effb7bf7f73332ecb31c886 Mon Sep 17 00:00:00 2001 From: KBAHTNET Date: Wed, 24 Jun 2026 09:23:58 +0300 Subject: [PATCH] first commit --- README.md | 128 ++++ Recommendation.md | 715 ++++++++++++++++++ admin-ui/.env.example | 18 + admin-ui/.gitignore | 2 + admin-ui/package.json | 13 + admin-ui/public/app.js | 282 +++++++ admin-ui/public/index.html | 129 ++++ admin-ui/public/styles.css | 139 ++++ admin-ui/server.js | 305 ++++++++ admin-ui/start.sh | 29 + check-sudo.sh | 104 +++ docker/.env.example | 33 + docker/.gitignore | 6 + docker/Dockerfile.ssl | 10 + docker/add-user.sh | 85 +++ docker/backup.sh | 49 ++ docker/certs/README.txt | 3 + docker/config.defaults/.htaccess | 28 + .../apache-pretty-urls.config.php | 4 + docker/config.defaults/apcu.config.php | 4 + docker/config.defaults/apps.config.php | 15 + docker/config.defaults/autoconfig.php | 41 + docker/config.defaults/redis.config.php | 21 + .../config.defaults/reverse-proxy.config.php | 35 + docker/config.defaults/s3.config.php | 48 ++ docker/config.defaults/smtp.config.php | 22 + docker/config.defaults/swift.config.php | 31 + .../upgrade-disable-web.config.php | 4 + docker/disable-docker-ssl.sh | 28 + docker/docker-compose.ssl.yml | 16 + docker/docker-compose.yml | 114 +++ docker/enable-docker-ssl.sh | 96 +++ docker/install-docker.sh | 74 ++ docker/install.sh | 213 ++++++ docker/restore-config.sh | 76 ++ docker/restore.sh | 33 + docker/setup-env.sh | 53 ++ docker/start.sh | 14 + docker/status.sh | 16 + docker/stop.sh | 9 + docker/templates/apache-ssl-inside.conf | 20 + docker/uninstall.sh | 30 + docker/user.txt | 2 + lib/common.sh | 442 +++++++++++ lib/node-server.js | 189 +++++ native/.env.example | 33 + native/backup.sh | 35 + native/install.sh | 167 ++++ native/restore.sh | 38 + native/setup-env.sh | 39 + native/start.sh | 7 + native/status.sh | 34 + native/stop.sh | 7 + native/templates/apache-subdir.conf.template | 14 + native/templates/apache-vhost.conf.template | 20 + native/templates/cron-nextcloud.template | 6 + native/templates/php-nextcloud.ini.template | 13 + native/uninstall.sh | 33 + register-ui/.env.example | 31 + register-ui/.gitignore | 2 + register-ui/package.json | 13 + register-ui/public/app.js | 71 ++ register-ui/public/index.html | 72 ++ register-ui/public/styles.css | 110 +++ register-ui/server.js | 180 +++++ register-ui/start.sh | 33 + 66 files changed, 4686 insertions(+) create mode 100644 README.md create mode 100644 Recommendation.md create mode 100644 admin-ui/.env.example create mode 100644 admin-ui/.gitignore create mode 100644 admin-ui/package.json create mode 100644 admin-ui/public/app.js create mode 100644 admin-ui/public/index.html create mode 100644 admin-ui/public/styles.css create mode 100644 admin-ui/server.js create mode 100644 admin-ui/start.sh create mode 100644 check-sudo.sh create mode 100644 docker/.env.example create mode 100644 docker/.gitignore create mode 100644 docker/Dockerfile.ssl create mode 100644 docker/add-user.sh create mode 100644 docker/backup.sh create mode 100644 docker/certs/README.txt create mode 100644 docker/config.defaults/.htaccess create mode 100644 docker/config.defaults/apache-pretty-urls.config.php create mode 100644 docker/config.defaults/apcu.config.php create mode 100644 docker/config.defaults/apps.config.php create mode 100644 docker/config.defaults/autoconfig.php create mode 100644 docker/config.defaults/redis.config.php create mode 100644 docker/config.defaults/reverse-proxy.config.php create mode 100644 docker/config.defaults/s3.config.php create mode 100644 docker/config.defaults/smtp.config.php create mode 100644 docker/config.defaults/swift.config.php create mode 100644 docker/config.defaults/upgrade-disable-web.config.php create mode 100644 docker/disable-docker-ssl.sh create mode 100644 docker/docker-compose.ssl.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/enable-docker-ssl.sh create mode 100644 docker/install-docker.sh create mode 100644 docker/install.sh create mode 100644 docker/restore-config.sh create mode 100644 docker/restore.sh create mode 100644 docker/setup-env.sh create mode 100644 docker/start.sh create mode 100644 docker/status.sh create mode 100644 docker/stop.sh create mode 100644 docker/templates/apache-ssl-inside.conf create mode 100644 docker/uninstall.sh create mode 100644 docker/user.txt create mode 100644 lib/common.sh create mode 100644 lib/node-server.js create mode 100644 native/.env.example create mode 100644 native/backup.sh create mode 100644 native/install.sh create mode 100644 native/restore.sh create mode 100644 native/setup-env.sh create mode 100644 native/start.sh create mode 100644 native/status.sh create mode 100644 native/stop.sh create mode 100644 native/templates/apache-subdir.conf.template create mode 100644 native/templates/apache-vhost.conf.template create mode 100644 native/templates/cron-nextcloud.template create mode 100644 native/templates/php-nextcloud.ini.template create mode 100644 native/uninstall.sh create mode 100644 register-ui/.env.example create mode 100644 register-ui/.gitignore create mode 100644 register-ui/package.json create mode 100644 register-ui/public/app.js create mode 100644 register-ui/public/index.html create mode 100644 register-ui/public/styles.css create mode 100644 register-ui/server.js create mode 100644 register-ui/start.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..436b04e --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Nextcloud — скрипты развёртывания + +| Способ | Каталог | Когда выбирать | +|--------|---------|----------------| +| **Docker** | [`docker/`](docker/) | Быстрый старт, изоляция, простые обновления | +| **Нативная** | [`native/`](native/) | Полная установка в ОС (LAMP) | + +📖 **[Recommendation.md](Recommendation.md)** — настройки, хранилище, HTTPS, приложения. + +## Структура + +``` +nextcloud_install/ +├── README.md +├── Recommendation.md +├── docker/ # Nextcloud в Docker +│ ├── install.sh +│ ├── enable-docker-ssl.sh # HTTPS :8443 (cert в контейнере) +│ ├── add-user.sh +│ ├── docker-compose.yml +│ └── certs/ # fullchain.pem, privkey.pem +├── register-ui/ # Самoregистрация пользователей (:3001) +├── admin-ui/ # Управление пользователями (:3000) +└── native/ # Установка в систему +``` + +--- + +## Docker — порты + +| Порт | Протокол | Назначение | +|------|----------|------------| +| **8080** | HTTP | `NEXTCLOUD_PORT` | +| **8443** | HTTPS | `./enable-docker-ssl.sh` + cert в `docker/certs/` | + +Reverse proxy **не используется** — Nextcloud напрямую. + +### Установка + +```bash +cd nextcloud_install/docker +chmod +x *.sh +./install.sh your.domain +``` + +Запускайте **без `sudo`**. Скрипт сам попросит sudo только для создания каталогов вроде `/var/nextcloud/*`. + +Скрипт спросит пути (все **вне** `nextcloud_install`): + +| Переменная | Назначение | По умолчанию | +|---|---|---| +| `NEXTCLOUD_CONFIG_DIR` | config.php, настройки | `/var/nextcloud/config` | +| `NEXTCLOUD_DATADIR` | Файлы пользователей | `/mnt/hgfs/nextcloud_data` | +| `NEXTCLOUD_APPDATA` | App Store, превью | `/var/nextcloud/appdata` | +| `NEXTCLOUD_CUSTOM_APPS` | Приложения из магазина | `/var/nextcloud/custom_apps` | +| `NEXTCLOUD_BACKUP_DIR` | Бэкапы | `/var/nextcloud/backups` | + +`nextcloud_install` — только скрипты, compose, шаблоны (`config.defaults/`). + +Переустановка: + +```bash +./uninstall.sh --purge # удалит контейнеры и Docker volumes, backup старого config.php +rm -f .env +./setup-env.sh your.domain +./install.sh your.domain +``` + +Если видите ошибку про `CAN_INSTALL` или права на config: + +```bash +./restore-config.sh --force +``` + +HTTPS: + +```bash +./enable-docker-ssl.sh your.domain +# https://your.domain:8443 +``` + +--- + +## Самoregистрация пользователей + +```bash +cd nextcloud_install/register-ui +chmod +x start.sh +./start.sh +``` + +Откройте **http://ваш-сервер:3001** — пользователи создают аккаунт сами. + +В `register-ui/.env`: +- `NEXTCLOUD_URL=https://host:8443` +- `REGISTRATION_ADMIN_USER=admin` +- `REGISTRATION_ADMIN_PASSWORD=...` (из `docker/.env`) +- `DEFAULT_QUOTA=5GB` + +Отключить регистрацию: `REGISTRATION_ENABLED=false` + +--- + +## Admin UI (для администратора) + +```bash +cd nextcloud_install/admin-ui +./start.sh +``` + +**http://127.0.0.1:3000** — квоты, группы, удаление пользователей. + +CLI: `docker/add-user.sh` + +--- + +## Native + +```bash +cd nextcloud_install/native +./install.sh +``` + +--- + +## Документация + +- [Nextcloud Admin Manual](https://docs.nextcloud.com/server/latest/admin_manual/) diff --git a/Recommendation.md b/Recommendation.md new file mode 100644 index 0000000..74ec483 --- /dev/null +++ b/Recommendation.md @@ -0,0 +1,715 @@ +# Рекомендации по настройке Nextcloud + +Практическое руководство для развёртываний из этого репозитория (`docker/` и `native/`): +важные настройки, расширение хранилища, HTTPS и работа со **своим SSL-сертификатом**. + +--- + +## Содержание + +1. [Первые шаги после установки](#1-первые-шаги-после-установки) +2. [Важные настройки администратора](#2-важные-настройки-администратора) +3. [Расширение хранилища](#3-расширение-хранилища) +4. [Производительность](#4-производительность) +5. [Безопасность](#5-безопасность) +6. [Email и уведомления](#6-email-и-уведомления) +7. [Полезные приложения](#7-полезные-приложения) +8. [Мониторинг и обслуживание](#8-мониторинг-и-обслуживание) +9. [Резервное копирование](#9-резервное-копирование) +10. [HTTPS и свой SSL-сертификат](#10-https-docker--напрямую-на-портах-8080--8443) +11. [Переустановка (Docker)](#11-переустановка-docker) +12. [Самoregистрация пользователей](#12-самoregистрация-пользователей) + +--- + +## 1. Первые шаги после установки + +### Проверка статуса + +**Docker:** +```bash +cd nextcloud_install/docker +./status.sh +docker compose exec -u www-data app php occ status +``` + +**Native:** +```bash +cd nextcloud_install/native +./status.sh +sudo -u www-data php /var/www/nextcloud/occ status +``` + +### Что должно быть «зелёным» в админке + +Откройте **Настройки → Администрирование → Обзор** и убедитесь: + +| Проверка | Ожидание | +|----------|----------| +| Фоновые задачи | **Cron** (настроено скриптами установки) | +| HTTPS | Включён для production | +| Redis | Подключён | +| Версия PHP | Поддерживаемая (см. [system requirements](https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html)) | +| Заголовки безопасности | Без критичных предупреждений | + +### Доверенные домены + +В `.env` (Docker) или `native/.env` — значение с пробелами **в двойных кавычках**: + +```env +NEXTCLOUD_TRUSTED_DOMAINS="localhost 127.0.0.1 cloud.example.com" +``` + +Через CLI: +```bash +occ config:system:set trusted_domains 0 --value="localhost" +occ config:system:set trusted_domains 1 --value="127.0.0.1" +occ config:system:set trusted_domains 2 --value="cloud.example.com" +``` + +--- + +## 2. Важные настройки администратора + +### Разделы админ-панели + +| Раздел | Назначение | +|--------|------------| +| **Обзор** | Диагностика, предупреждения | +| **Безопасность и параметры входа** | 2FA, политика паролей, bruteforce | +| **Общие** | URL сервера, язык, лимиты | +| **Хранилище** | Квоты пользователей и групп | +| **Основные параметры сервера** | Email, логирование | +| **Приложения** | Установка расширений | + +### Часто используемые команды `occ` + +```bash +# Список пользователей +occ user:list + +# Создать пользователя +occ user:add bob --password-from-env +# (предварительно: export OC_PASS='...') + +# Квота 50 GB +occ user:setting bob files quota 50GB + +# Без лимита +occ user:setting bob files quota none + +# Просмотр системных настроек +occ config:list system + +# Проверка обновлений +occ update:check +occ upgrade +``` + +**Docker:** `docker compose exec -u www-data app php occ …` +**Native:** `sudo -u www-data php /var/www/nextcloud/occ …` + +--- + +## 3. Расширение хранилища + +### Уровень 1: Квота пользователя (не физический диск) + +Ограничивает пользователя в Nextcloud, **не добавляет место на диске**. + +```bash +occ user:setting alice files quota 50GB +``` + +Или: **Настройки → Пользователи → квота**. + +--- + +### Уровень 2: Расширение диска / тома (основной способ) + +Nextcloud использует каталог `data/` для файлов. Если на диске больше места — Nextcloud его увидит автоматически. + +#### Docker (`nextcloud_install/docker`) + +Данные по умолчанию в Docker volume **`nextcloud_app_data`**. + +**Посмотреть расположение:** +```bash +docker volume inspect nextcloud_app_data +df -h $(docker volume inspect nextcloud_app_data -f '{{.Mountpoint}}') +``` + +**Варианты:** + +| Способ | Когда | +|--------|-------| +| Увеличить диск VPS/VM | Облако | +| Расширить LVM/partition | `lvextend` + `resize2fs` | +| Bind mount на большой раздел | Нужен явный путь на хосте | + +**Перенос data на отдельный диск (bind mount):** + +1. `./backup.sh` +2. `./stop.sh` +3. Скопировать данные: + ```bash + sudo mkdir -p /mnt/bigdisk/nextcloud-data + sudo rsync -aH \ + $(docker volume inspect nextcloud_app_data -f '{{.Mountpoint}}')/ \ + /mnt/bigdisk/nextcloud-data/ + ``` +4. В `.env` задайте путь: + ```env + NEXTCLOUD_DATADIR=/mnt/bigdisk/nextcloud-data + ``` +5. `./start.sh` + +> После bind mount файлы пользователей лежат на хосте в каталоге из `NEXTCLOUD_DATADIR`. + +#### Перенос data ещё раз или очистка и запуск в новом месте (Docker) + +Файлы пользователей — это каталог на хосте из **`NEXTCLOUD_DATADIR`** (сейчас по умолчанию `/mnt/hgfs/nextcloud_data`). +Пользователи, shares и метаданные — в **MariaDB**. Код Nextcloud — в volume **`nextcloud_app_data`**. + +--- + +**Сценарий A — перенести файлы в другое место (всё сохранить)** + +```bash +cd nextcloud_install/docker + +# 1. Бэкап (рекомендуется) +./backup.sh + +# 2. Остановить стек +./stop.sh + +# 3. Скопировать data из старого пути в новый +OLD=/mnt/hgfs/nextcloud_data # текущий NEXTCLOUD_DATADIR +NEW=/mnt/bigdisk/nextcloud_data # новый путь + +sudo mkdir -p "$NEW" +sudo rsync -aH "$OLD/" "$NEW/" +sudo chown -R 33:33 "$NEW" + +# 4. Обновить .env +# NEXTCLOUD_DATADIR=/mnt/bigdisk/nextcloud_data + +# 5. Запустить +./start.sh + +# 6. Пересканировать файлы +docker compose exec -u www-data app php occ files:scan --all +``` + +Старый каталог `$OLD` можно удалить **после проверки**, что Nextcloud открывается и файлы на месте. + +--- + +**Сценарий B — очистить файлы и указать новое пустое место (пользователи в Nextcloud остаются)** + +Подходит, если нужен «пустой диск» для файлов, но логины/настройки сохранить. + +```bash +cd nextcloud_install/docker +./stop.sh + +# Новый пустой каталог +NEW=/mnt/newdisk/nextcloud_data +sudo rm -rf "$NEW"/* "$NEW"/.[!.]* 2>/dev/null || true +sudo mkdir -p "$NEW" +sudo chown -R 33:33 "$NEW" + +# .env → NEXTCLOUD_DATADIR=/mnt/newdisk/nextcloud_data + +./start.sh +docker compose exec -u www-data app php occ maintenance:mode --on +docker compose exec -u www-data app php occ files:scan --all +docker compose exec -u www-data app php occ maintenance:repair +docker compose exec -u www-data app php occ maintenance:mode --off +``` + +В веб-интерфейсе старые файлы могут ещё отображаться как «отсутствующие» — это записи в БД. +Чтобы убрать их полностью, удалите файлы через UI или очистите БД (сценарий C). + +--- + +**Сценарий C — полный сброс (чистый Nextcloud с нуля)** + +Удаляются контейнеры, Docker volumes (БД, код Nextcloud) и создаётся новая установка. +Подробные шаги — в разделе **[11. Переустановка (Docker)](#11-переустановка-docker)**. + +--- + +**Шпаргалка: что где лежит** — см. [§11. Переустановка (Docker)](#11-переустановка-docker). + +--- + +```bash +df -h /var/www/nextcloud/data +du -sh /var/www/nextcloud/data +``` + +**Перенос на другой диск:** +```bash +sudo ./backup.sh +sudo systemctl stop apache2 + +sudo mkdir -p /mnt/bigdisk/nextcloud-data +sudo rsync -aH /var/www/nextcloud/data/ /mnt/bigdisk/nextcloud-data/ + +# Вариант A: symlink +sudo mv /var/www/nextcloud/data /var/www/nextcloud/data.old +sudo ln -s /mnt/bigdisk/nextcloud-data /var/www/nextcloud/data + +# Вариант B: правка config.php (предпочтительнее) +# 'datadirectory' => '/mnt/bigdisk/nextcloud-data', + +sudo chown -R www-data:www-data /mnt/bigdisk/nextcloud-data +sudo systemctl start apache2 +``` + +В `config/config.php`: +```php +'datadirectory' => '/mnt/bigdisk/nextcloud-data', +``` + +--- + +### Уровень 3: Внешнее хранилище + +**Приложения → External storage support** + +| Тип | Применение | +|-----|------------| +| **Local** | Доп. папка на сервере (`/mnt/archive`) | +| **S3 / MinIO** | Объектное хранилище | +| **SFTP / SMB** | Сетевые шары | +| **Primary storage (S3)** | Все файлы сразу в S3 (сложная настройка) | + +Для архива и редко используемых файлов — external storage. +Для основного объёма — расширение `data/` или S3 primary storage. + +**S3 primary storage** (только на новой установке или после полной миграции): +```php +'objectstore' => [ + 'class' => '\\OC\\Files\\ObjectStore\\S3', + 'arguments' => [ + 'bucket' => 'nextcloud', + 'key' => 'ACCESS_KEY', + 'secret' => 'SECRET_KEY', + 'hostname' => 's3.example.com', + 'port' => 443, + 'use_ssl' => true, + 'region' => 'us-east-1', + ], +], +``` + +--- + +### Схема выбора + +``` +Нужно больше места? +│ +├─ Ограничить пользователя → occ user:setting … quota +│ +├─ Закончился диск сервера → расширить раздел / volume +│ +├─ Есть второй диск → bind mount / datadirectory +│ +└─ Облако / S3 → External storage или S3 primary +``` + +--- + +## 4. Производительность + +### Уже настроено скриптами установки + +- **Redis** — распределённый кэш и блокировки +- **Cron** — фоновые задачи каждые 5 минут +- **PHP** — лимиты в `.env` (`PHP_MEMORY_LIMIT`, `PHP_UPLOAD_LIMIT`) + +### Дополнительный тюнинг + +```bash +occ config:system:set memcache.local --value="\OC\Memcache\APCu" +occ config:system:set memcache.distributed --value="\OC\Memcache\Redis" +occ config:system:set memcache.locking --value="\OC\Memcache\Redis" +``` + +| Параметр | Рекомендация | +|----------|--------------| +| `PHP_MEMORY_LIMIT` | 512M–1024M | +| `PHP_UPLOAD_LIMIT` | По max. размеру файлов (напр. 10G) | +| Preview Generator | App для предгенерации миниатюр | +| `occ files:scan` | После переноса data | + +**Preview Generator:** +```bash +occ app:install previewgenerator +occ config:app:set previewgenerator squareSizes --value="256 512" +occ config:system:set enable_previews --value=true --type=boolean +``` + +--- + +## 5. Безопасность + +### Минимум для production + +- [ ] HTTPS (см. [раздел 10](#10-https-и-свой-ssl-сертификат)) +- [ ] Сильные пароли (в `.env` генерируются автоматически) +- [ ] Двухфакторная аутентификация (2FA) +- [ ] Регулярные обновления (`occ upgrade`) +- [ ] Бэкапы (скрипты `backup.sh`) + +### Режим обслуживания + +```bash +occ maintenance:mode --on # перед обновлением / восстановлением +occ maintenance:mode --off +``` + +### Server-side encryption + +Шифрование на стороне сервера **необратимо без ключей восстановления**. +Включайте только если понимаете процедуру recovery key. + +### Bruteforce + +Nextcloud блокирует IP при переборе паролей. Логи: +```bash +occ security:bruteforce:attempts 203.0.113.10 +``` + +Дополнительно можно поставить **fail2ban** на Apache/Nginx. + +--- + +## 6. Email и уведомления + +**Настройки → Основные → Сервер исходящей почты** + +Или в `config/config.php`: +```php +'mail_smtpmode' => 'smtp', +'mail_smtphost' => 'smtp.example.com', +'mail_smtpport' => 587, +'mail_smtpsecure' => 'tls', +'mail_smtpauth' => 1, +'mail_smtpname' => 'nextcloud@example.com', +'mail_smtppassword' => 'secret', +'mail_from_address' => 'nextcloud', +'mail_domain' => 'example.com', +``` + +Проверка: +```bash +occ mail:test --account admin +``` + +--- + +## 7. Полезные приложения + +```bash +occ app:list +occ app:install groupfolders +occ app:install previewgenerator +``` + +| Приложение | Назначение | +|------------|------------| +| **Group folders** | Общие папки для команд | +| **External storage** | S3, SMB, SFTP | +| **Preview generator** | Быстрые превью | +| **Quota warning** | Уведомления о заполнении | +| **Calendar / Contacts** | CalDAV / CardDAV | +| **Office** | Collabora / ONLYOFFICE | + +--- + +## 8. Мониторинг и обслуживание + +```bash +# Место на диске +df -h + +# Docker: размер data +du -sh $(docker volume inspect nextcloud_app_data -f '{{.Mountpoint}}') + +# Native +du -sh /var/www/nextcloud/data + +# Пересканировать файлы (после переноса) +occ files:scan --all + +# Статус +occ status +``` + +В админке: **Настройки → Системная информация**. + +--- + +## 9. Резервное копирование + +### Что обязательно бэкапить + +| Компонент | Содержимое | +|-----------|------------| +| **База данных** | Пользователи, метаданные, shares | +| **`NEXTCLOUD_DATADIR`** | Файлы пользователей | +| **`NEXTCLOUD_CONFIG_DIR`** | config.php и фрагменты настроек | +| **`NEXTCLOUD_APPDATA`** | App Store, превью, аватары | +| **`NEXTCLOUD_CUSTOM_APPS`** | Приложения из магазина | + +### Скрипты этого репозитория + +```bash +# Docker +cd nextcloud_install/docker && ./backup.sh + +# Native +cd nextcloud_install/native && sudo ./backup.sh +``` + +Архивы сохраняются в **`NEXTCLOUD_BACKUP_DIR`** (по умолчанию `/var/nextcloud/backups/`). +**Копируйте их на внешнее хранилище.** + +### Восстановление + +```bash +./restore.sh backups/nextcloud_*_YYYY-MM-DD_*.tar.gz +``` + +### Рекомендуемая периодичность + +| Тип | Частота | +|-----|---------| +| Полный бэкап | Ежедневно | +| Проверка restore | Раз в квартал на тестовой машине | +| Offsite-копия | Сразу после бэкапа (rsync, S3, Borg и т.д.) | + +--- + +## 10. HTTPS (Docker — напрямую на портах 8080 / 8443) + +Reverse proxy **не используется**. Nextcloud доступен напрямую: + +| Порт | Протокол | Назначение | +|------|----------|------------| +| **8080** | HTTP | `NEXTCLOUD_PORT` в `.env` | +| **8443** | HTTPS | `NEXTCLOUD_HTTPS_PORT`, сертификат внутри Docker | + +### Включить HTTPS в Docker + +```bash +cd nextcloud_install/docker +cp /path/to/fullchain.pem certs/ +cp /path/to/privkey.pem certs/ +./enable-docker-ssl.sh your.domain +``` + +Скрипт монтирует `certs/` в контейнер и настраивает Apache + mod_ssl. + +Отключить: `./disable-docker-ssl.sh` + +### Native (без Docker) + +SSL настраивается в Apache на хосте — см. [example_ubuntu](https://docs.nextcloud.com/server/latest/admin_manual/installation/example_ubuntu.html). + +--- + +## 11. Переустановка (Docker) + +Каталог **`nextcloud_install`** — только скрипты развёртывания. +Все данные Nextcloud лежат **вне** него, пути задаются в `.env` (при `./install.sh` скрипт спросит их интерактивно). + +### Что где хранится + +| Переменная в `.env` | Содержимое | По умолчанию | +|---------------------|------------|--------------| +| `NEXTCLOUD_CONFIG_DIR` | config.php, настройки | `/var/nextcloud/config` | +| `NEXTCLOUD_DATADIR` | Файлы пользователей | `/mnt/hgfs/nextcloud_data` | +| `NEXTCLOUD_APPDATA` | App Store, превью, аватары | `/var/nextcloud/appdata` | +| `NEXTCLOUD_CUSTOM_APPS` | Приложения из магазина | `/var/nextcloud/custom_apps` | +| `NEXTCLOUD_BACKUP_DIR` | Архивы `./backup.sh` | `/var/nextcloud/backups` | +| `NEXTCLOUD_INSTANCEID` | ID экземпляра (для mount appdata_*) | генерируется в `.env` | + +В контейнере: + +| Путь в контейнере | Источник | +|-------------------|----------| +| `/var/www/html/config` | `NEXTCLOUD_CONFIG_DIR` | +| `/var/www/html/data` | `NEXTCLOUD_DATADIR` | +| `/var/www/html/data/appdata_` | `NEXTCLOUD_APPDATA` | +| `/var/www/html/custom_apps` | `NEXTCLOUD_CUSTOM_APPS` | + +Docker volumes (удаляются при `--purge`): **`nextcloud_db_data`** (MariaDB), **`nextcloud_app_data`** (код Nextcloud), **`nextcloud_redis_data`**. + +### Важно перед установкой + +**Запускайте скрипты БЕЗ `sudo`:** `./install.sh`, `./start.sh`, `./stop.sh`. +Скрипт сам запросит `sudo` только для создания каталогов в `/var/nextcloud/` и т.п. + +```bash +cd nextcloud_install/docker +./install.sh cloud.example.com # не sudo ./install.sh +``` + +### Вариант A — полная переустановка (с нуля) + +Удаляются контейнеры, БД, код Nextcloud в volume. Пользователи и настройки **не сохраняются**. + +```bash +cd nextcloud_install/docker + +./backup.sh # по желанию +./stop.sh +./uninstall.sh --purge # ответ: yes + +# Очистить данные на хосте (если нужен чистый старт) +sudo rm -rf /var/nextcloud/appdata/* /var/nextcloud/custom_apps/* +# sudo rm -rf /mnt/hgfs/nextcloud_data/* # только если файлы пользователей не нужны + +rm -f .env +./setup-env.sh cloud.example.com +./install.sh cloud.example.com # спросит пути; Enter = значения по умолчанию +./enable-docker-ssl.sh cloud.example.com +``` + +После `./setup-env.sh` запишите пароль admin из вывода (он же в `.env` → `NEXTCLOUD_ADMIN_PASSWORD`). + +### Вариант B — переустановка с сохранением файлов пользователей + +БД и config сбрасываются, но каталог **`NEXTCLOUD_DATADIR`** не трогаем: + +```bash +cd nextcloud_install/docker +./backup.sh +./stop.sh +./uninstall.sh --purge +rm -f .env +./setup-env.sh cloud.example.com +./install.sh cloud.example.com +# NEXTCLOUD_DATADIR оставить тот же путь, что был раньше +./enable-docker-ssl.sh cloud.example.com + +docker compose exec -u www-data app php occ files:scan --all +``` + +### Вариант C — только пересоздать config + +Если config повреждён, нет прав, или в браузере «нужен CAN_INSTALL»: + +```bash +cd nextcloud_install/docker + +# Полный сброс config + переустановка через occ (рекомендуется) +./restore-config.sh --force + +# Или сброс + веб-мастер в браузере (создаёт CAN_INSTALL) +./restore-config.sh --web +``` + +`--force` — очистит `NEXTCLOUD_CONFIG_DIR`, восстановит шаблоны, выставит права **www-data** и запустит `occ maintenance:install`. + +`--web` — то же, но вместо occ создаёт файл `CAN_INSTALL` для установки через браузер. + +Без флагов — только дополняет недостающие файлы и чинит права: + +```bash +./restore-config.sh +``` + +### Что делает `./uninstall.sh --purge` + +`--purge` удаляет Docker volumes с БД и кодом Nextcloud. Чтобы следующая установка не падала в ошибку +«нужен `CAN_INSTALL`», скрипт также делает backup старого `config.php` и удаляет активный `config.php`. +Остальные файлы и каталоги на хосте остаются. + +| Удаляется | Не удаляется | +|-----------|--------------| +| Контейнеры Docker | `NEXTCLOUD_CONFIG_DIR` (кроме активного `config.php`) | +| Volume `nextcloud_db_data` | `NEXTCLOUD_DATADIR` | +| Volume `nextcloud_app_data` | `NEXTCLOUD_APPDATA` | +| Volume `nextcloud_redis_data` | `NEXTCLOUD_CUSTOM_APPS` | +| Файл `.env` (только если удалите вручную) | `NEXTCLOUD_BACKUP_DIR` | + +### Важно после переустановки + +1. **Доверенные домены** в `.env` — в кавычках: + ```env + NEXTCLOUD_TRUSTED_DOMAINS="localhost 127.0.0.1 cloud.example.com" + ``` +2. **HTTPS:** `./enable-docker-ssl.sh your.domain` (порт 8443). +3. **register-ui / admin-ui:** обновите `NEXTCLOUD_URL` в их `.env`. +4. **App Store:** appdata должен быть на локальном диске, не на hgfs (`NEXTCLOUD_APPDATA`). + +### Типичные проблемы + +| Симптом | Решение | +|---------|---------| +| `Permission denied` при записи config | `./restore-config.sh --force` | +| Нет `config.php` | `./restore-config.sh --force` | +| «Нужен CAN_INSTALL» в браузере | `./restore-config.sh --force` или `./restore-config.sh --web` | +| Нет прав на config | `./restore-config.sh --force` (sudo для chown www-data) | +| `Failed opening required .../lib/versioncheck.php` | Запустите `./start.sh`: скрипт пересоздаст битый `nextcloud_app_data` | +| «Центр приложений» не открывается | Проверьте права на `NEXTCLOUD_APPDATA`, каталог не на hgfs | +| Trusted domains не работают | Проверьте кавычки в `.env` вокруг `NEXTCLOUD_TRUSTED_DOMAINS` | + +--- + +## 12. Самoregистрация пользователей + +Страница **`register-ui/`** — пользователи создают аккаунт сами: + +```bash +cd nextcloud_install/register-ui +chmod +x start.sh +./start.sh +``` + +Откройте **http://ваш-сервер:3001** + +Настройка в `register-ui/.env`: + +| Переменная | Описание | +|------------|----------| +| `NEXTCLOUD_URL` | `https://host:8443` или `http://host:8080` | +| `REGISTRATION_ADMIN_USER` | admin (создаёт пользователей через API) | +| `REGISTRATION_ADMIN_PASSWORD` | пароль admin | +| `DEFAULT_QUOTA` | квота новых пользователей (`5GB`, `none`) | +| `REGISTRATION_ENABLED` | `true` / `false` | +| `ALLOWED_EMAIL_DOMAINS` | опционально: `company.com` | + +Админ-панель для управления всеми пользователями: **`admin-ui/`** (порт 3000). + +--- + + +## Связанные файлы репозитория + +| Файл | Назначение | +|------|------------| +| [README.md](README.md) | Установка Docker / Native | +| `docker/.env` | Порт, домены, PHP-лимиты | +| `native/.env` | Путь установки, домен Apache | +| `docker/backup.sh` / `native/backup.sh` | Резервные копии | +| `docker/restore-config.sh` | Восстановление config после сбоя | +| `docker/uninstall.sh --purge` | Удаление стека (см. §11) | +| `register-ui/` | Самoregистрация пользователей | +| `admin-ui/` | Управление пользователями (admin) | +| `docker/enable-docker-ssl.sh` | HTTPS на порту 8443 | + +--- + +## Полезные ссылки + +- [Администрирование Nextcloud](https://docs.nextcloud.com/server/latest/admin_manual/) +- [System requirements](https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html) +- [External storage](https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/external_storage_configuration_gui.html) diff --git a/admin-ui/.env.example b/admin-ui/.env.example new file mode 100644 index 0000000..ae4ce05 --- /dev/null +++ b/admin-ui/.env.example @@ -0,0 +1,18 @@ +# URL Nextcloud (без слэша в конце) +NEXTCLOUD_URL=http://127.0.0.1:8080 + +# Порт веб-UI управления пользователями +ADMIN_UI_PORT=3000 + +# Секрет подписи cookie сессии +SESSION_SECRET=change-me-to-random-string + +# node:http (default) или node:http2 (HTTP/2 cleartext) +# HTTP2=true + +# HTTPS для UI (опционально, node:http2.createSecureServer) +# SSL_KEY=./certs/privkey.pem +# SSL_CERT=./certs/fullchain.pem + +# Опционально: только эти IP (через запятую) +# ALLOWED_IPS=127.0.0.1,::1 diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/admin-ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/admin-ui/package.json b/admin-ui/package.json new file mode 100644 index 0000000..00bb194 --- /dev/null +++ b/admin-ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "nextcloud-admin-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Web UI for Nextcloud user management (node:http / node:http2)", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/admin-ui/public/app.js b/admin-ui/public/app.js new file mode 100644 index 0000000..ec2044d --- /dev/null +++ b/admin-ui/public/app.js @@ -0,0 +1,282 @@ +const $ = (sel) => document.querySelector(sel); + +const loginScreen = $('#login-screen'); +const mainScreen = $('#main-screen'); +const loginForm = $('#login-form'); +const loginError = $('#login-error'); +const usersBody = $('#users-body'); +const userDialog = $('#user-dialog'); +const userForm = $('#user-form'); +const globalError = $('#global-error'); +const globalOk = $('#global-ok'); + +let allGroups = []; +let allUsers = []; + +async function api(path, opts = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + ...opts, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function showError(msg) { + globalError.textContent = msg; + globalError.classList.remove('hidden'); + globalOk.classList.add('hidden'); + setTimeout(() => globalError.classList.add('hidden'), 6000); +} + +function showOk(msg) { + globalOk.textContent = msg; + globalOk.classList.remove('hidden'); + globalError.classList.add('hidden'); + setTimeout(() => globalOk.classList.add('hidden'), 4000); +} + +function formatBytes(n) { + if (n == null || n === '?' || isNaN(Number(n))) return '—'; + const b = Number(n); + if (b === 0) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), u.length - 1); + return `${(b / 1024 ** i).toFixed(1)} ${u[i]}`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +async function checkSession() { + try { + const me = await api('/api/me'); + loginScreen.classList.add('hidden'); + mainScreen.classList.remove('hidden'); + $('#server-info').textContent = `${me.username} @ ${me.nextcloudUrl}`; + await loadAll(); + } catch { + loginScreen.classList.remove('hidden'); + mainScreen.classList.add('hidden'); + } +} + +async function loadGroups() { + const { groups } = await api('/api/groups'); + allGroups = groups || []; + const sel = $('#field-groups'); + sel.innerHTML = allGroups.map((g) => ``).join(''); +} + +async function loadUsers() { + usersBody.innerHTML = 'Загрузка…'; + const { users } = await api('/api/users'); + allUsers = users; + + if (!users.length) { + usersBody.innerHTML = 'Нет пользователей'; + return; + } + + usersBody.innerHTML = users.map((u) => ` + + ${escapeHtml(u.id)} + ${escapeHtml(u.displayName || '—')} + ${escapeHtml(u.email || '—')} + ${escapeHtml(String(u.quota || 'default'))} + ${formatBytes(u.used)} + ${(u.groups || []).map((g) => `${escapeHtml(g)}`).join('') || '—'} + ${u.enabled ? 'активен' : 'отключён'} + + + + + + `).join(''); + + document.querySelectorAll('.btn-edit').forEach((btn) => { + btn.addEventListener('click', () => openEdit(btn.dataset.id)); + }); + document.querySelectorAll('.btn-del').forEach((btn) => { + btn.addEventListener('click', () => deleteUser(btn.dataset.id)); + }); +} + +async function loadAll() { + try { + await Promise.all([loadGroups(), loadUsers()]); + } catch (e) { + showError(e.message); + } +} + +function getQuotaValue() { + const sel = $('#field-quota').value; + if (sel === 'custom') return $('#field-quota-custom').value.trim(); + if (sel === 'none') return 'none'; + if (sel === 'default') return 'default'; + return sel; +} + +function setQuotaSelect(quota) { + const q = String(quota || 'default'); + const sel = $('#field-quota'); + const opts = [...sel.options].map((o) => o.value); + if (q === 'none' || q === 'default') { + sel.value = q; + } else if (opts.includes(q)) { + sel.value = q; + } else { + sel.value = 'custom'; + $('#field-quota-custom').value = q; + } + $('#custom-quota-wrap').classList.toggle('hidden', sel.value !== 'custom'); +} + +function openCreate() { + $('#dialog-title').textContent = 'Новый пользователь'; + $('#edit-userid').value = ''; + $('#field-userid').value = ''; + $('#field-userid').disabled = false; + $('#field-password').value = ''; + $('#field-password').required = true; + $('#password-hint').textContent = 'Обязателен для нового пользователя'; + $('#field-displayname').value = ''; + $('#field-email').value = ''; + setQuotaSelect('default'); + $('#field-enabled').checked = true; + $('#field-enabled-wrap').classList.add('hidden'); + [...$('#field-groups').options].forEach((o) => { o.selected = o.value === 'users'; }); + userDialog.showModal(); +} + +function openEdit(userid) { + const u = allUsers.find((x) => x.id === userid); + if (!u) return; + + $('#dialog-title').textContent = `Редактировать: ${userid}`; + $('#edit-userid').value = userid; + $('#field-userid').value = userid; + $('#field-userid').disabled = true; + $('#field-password').value = ''; + $('#field-password').required = false; + $('#password-hint').textContent = 'Оставьте пустым, чтобы не менять'; + $('#field-displayname').value = u.displayName || ''; + $('#field-email').value = u.email || ''; + setQuotaSelect(u.quota); + $('#field-enabled').checked = u.enabled !== false; + $('#field-enabled-wrap').classList.remove('hidden'); + + const groups = u.groups || []; + [...$('#field-groups').options].forEach((o) => { o.selected = groups.includes(o.value); }); + + userDialog.showModal(); +} + +async function deleteUser(id) { + if (!confirm(`Удалить пользователя «${id}»? Файлы будут удалены.`)) return; + try { + await api(`/api/users/${encodeURIComponent(id)}`, { method: 'DELETE' }); + showOk(`Пользователь ${id} удалён`); + await loadUsers(); + } catch (e) { + showError(e.message); + } +} + +loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + loginError.classList.add('hidden'); + try { + await api('/api/login', { + method: 'POST', + body: JSON.stringify({ + username: $('#login-user').value.trim(), + password: $('#login-pass').value, + }), + }); + await checkSession(); + } catch { + loginError.textContent = 'Неверный логин или пароль'; + loginError.classList.remove('hidden'); + } +}); + +$('#btn-logout').addEventListener('click', async () => { + await api('/api/logout', { method: 'POST' }); + location.reload(); +}); + +$('#btn-refresh').addEventListener('click', loadAll); +$('#btn-create').addEventListener('click', openCreate); +$('#btn-cancel').addEventListener('click', () => userDialog.close()); + +$('#field-quota').addEventListener('change', () => { + $('#custom-quota-wrap').classList.toggle('hidden', $('#field-quota').value !== 'custom'); +}); + +userForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const editId = $('#edit-userid').value; + const quota = getQuotaValue(); + const selectedGroups = [...$('#field-groups').selectedOptions].map((o) => o.value); + + try { + if (editId) { + const body = { + quota, + displayName: $('#field-displayname').value.trim(), + email: $('#field-email').value.trim(), + enabled: $('#field-enabled').checked, + }; + const pass = $('#field-password').value; + if (pass) body.password = pass; + + await api(`/api/users/${encodeURIComponent(editId)}`, { + method: 'PATCH', + body: JSON.stringify(body), + }); + + const u = allUsers.find((x) => x.id === editId); + const currentGroups = u?.groups || []; + + for (const g of selectedGroups) { + if (!currentGroups.includes(g)) { + await api(`/api/users/${encodeURIComponent(editId)}/groups`, { + method: 'POST', + body: JSON.stringify({ groupid: g }), + }); + } + } + for (const g of currentGroups) { + if (!selectedGroups.includes(g) && g !== 'admin') { + await api(`/api/users/${encodeURIComponent(editId)}/groups/${encodeURIComponent(g)}`, { + method: 'DELETE', + }); + } + } + + showOk(`Пользователь ${editId} обновлён`); + } else { + const userid = $('#field-userid').value.trim(); + const password = $('#field-password').value; + if (!password) throw new Error('Укажите пароль'); + + await api('/api/users', { + method: 'POST', + body: JSON.stringify({ userid, password, quota, groups: selectedGroups }), + }); + showOk(`Пользователь ${userid} создан`); + } + + userDialog.close(); + await loadAll(); + } catch (err) { + showError(err.message); + } +}); + +checkSession(); diff --git a/admin-ui/public/index.html b/admin-ui/public/index.html new file mode 100644 index 0000000..d3f7670 --- /dev/null +++ b/admin-ui/public/index.html @@ -0,0 +1,129 @@ + + + + + + Nextcloud — управление пользователями + + + +
+ + + + + +
+ + + +
+

Новый пользователь

+ + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + diff --git a/admin-ui/public/styles.css b/admin-ui/public/styles.css new file mode 100644 index 0000000..59467c3 --- /dev/null +++ b/admin-ui/public/styles.css @@ -0,0 +1,139 @@ +:root { + --bg: #0f1419; + --card: #1a2332; + --border: #2d3a4d; + --text: #e7ecf3; + --muted: #8b9cb3; + --accent: #0082c9; + --accent-hover: #006aa3; + --danger: #e74c3c; + --ok: #27ae60; + --radius: 10px; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.hidden { display: none !important; } +.muted { color: var(--muted); } +.center { text-align: center; } +.error { color: #ff8a80; } +.hint { font-size: 0.85rem; color: var(--muted); display: block; margin-top: 4px; } + +#app { max-width: 1200px; margin: 0 auto; padding: 24px; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.login-card { max-width: 400px; margin: 10vh auto; } +.login-card h1 { margin-top: 0; } + +label { + display: block; + margin-bottom: 16px; + font-size: 0.9rem; +} + +input, select, button { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 1rem; +} + +select[multiple] { min-height: 100px; } + +button { + cursor: pointer; + background: var(--accent); + border: none; + font-weight: 600; +} +button:hover { background: var(--accent-hover); } +button.secondary { background: transparent; border: 1px solid var(--border); } +button.danger { background: var(--danger); } +button.small { width: auto; padding: 6px 12px; font-size: 0.85rem; } + +.checkbox { display: flex; align-items: center; gap: 8px; } +.checkbox input { width: auto; margin: 0; } + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.topbar h1 { margin: 0 0 4px; } +.topbar-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.topbar-actions button { width: auto; } + +.banner { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; +} +.banner.error { background: rgba(231,76,60,.15); border: 1px solid var(--danger); } +.banner.ok { background: rgba(39,174,96,.15); border: 1px solid var(--ok); } + +.table-wrap { overflow-x: auto; padding: 0; } +table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } +th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; } +tr:hover td { background: rgba(255,255,255,.02); } + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + background: var(--border); +} +.badge.on { background: rgba(39,174,96,.25); color: #7dcea0; } +.badge.off { background: rgba(231,76,60,.25); color: #ff8a80; } + +.group-tag { + display: inline-block; + margin: 2px 4px 2px 0; + padding: 2px 8px; + border-radius: 6px; + background: rgba(0,130,201,.2); + font-size: 0.78rem; +} + +.row-actions { display: flex; gap: 6px; } + +dialog { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + color: var(--text); + padding: 0; + max-width: 480px; + width: 92vw; +} +dialog::backdrop { background: rgba(0,0,0,.6); } +dialog form { padding: 24px; } +dialog h2 { margin-top: 0; } +.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } +.dialog-actions button { width: auto; min-width: 100px; } + +code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.85em; } diff --git a/admin-ui/server.js b/admin-ui/server.js new file mode 100644 index 0000000..7873138 --- /dev/null +++ b/admin-ui/server.js @@ -0,0 +1,305 @@ +import path from 'node:path'; +import { + loadEnv, + sendJson, + readJsonBody, + parseCookies, + setCookie, + signSession, + verifySession, + clientIp, + matchRoute, + serveStatic, + listen, + dirFromMeta, +} from '../lib/node-server.js'; + +const __dirname = dirFromMeta(import.meta.url); +loadEnv(path.join(__dirname, '.env')); + +const PORT = Number(process.env.ADMIN_UI_PORT || 3000); +const HOST = process.env.ADMIN_UI_HOST || '127.0.0.1'; +const USE_HTTP2 = process.env.HTTP2 === 'true'; +const NEXTCLOUD_URL = (process.env.NEXTCLOUD_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); +const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-secret-change-me'; +const SESSION_COOKIE = 'nc_admin'; +const PUBLIC_DIR = path.join(__dirname, 'public'); +const ALLOWED_IPS = (process.env.ALLOWED_IPS || '127.0.0.1,::1,::ffff:127.0.0.1') + .split(',').map((s) => s.trim()).filter(Boolean); + +const TLS = + process.env.SSL_KEY && process.env.SSL_CERT + ? { key: process.env.SSL_KEY, cert: process.env.SSL_CERT } + : null; + +function getSession(req) { + const cookies = parseCookies(req); + return verifySession(cookies[SESSION_COOKIE], SESSION_SECRET); +} + +function authHeader(user, pass) { + return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); +} + +async function ocsRequest(user, pass, method, ocsPath, { body, searchParams } = {}) { + const url = new URL(`${NEXTCLOUD_URL}${ocsPath}`); + if (searchParams) { + for (const [k, v] of Object.entries(searchParams)) url.searchParams.set(k, v); + } + const headers = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: authHeader(user, pass), + }; + const init = { method, headers }; + if (body && method !== 'GET') { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + init.body = new URLSearchParams(body).toString(); + } + const res = await fetch(url, init); + const data = JSON.parse(await res.text()); + const meta = data?.ocs?.meta; + if (!meta || meta.status !== 'ok') { + throw new Error(meta?.message || `OCS error ${meta?.statuscode || res.status}`); + } + return data.ocs.data; +} + +const routes = [ + { + method: 'POST', + path: '/api/login', + handler: async (req, res) => { + const { username, password } = await readJsonBody(req); + if (!username || !password) return sendJson(res, 400, { error: 'Укажите логин и пароль' }); + try { + await ocsRequest(username, password, 'GET', '/ocs/v1.php/cloud/users', { + searchParams: { offset: 0, limit: 1 }, + }); + setCookie(res, SESSION_COOKIE, signSession({ ncUser: username, ncPass: password }, SESSION_SECRET), { + maxAge: 8 * 3600, + }); + sendJson(res, 200, { ok: true, username }); + } catch { + sendJson(res, 401, { error: 'Неверный логин/пароль или нет прав администратора' }); + } + }, + }, + { + method: 'POST', + path: '/api/logout', + handler: async (_req, res) => { + setCookie(res, SESSION_COOKIE, '', { clear: true }); + sendJson(res, 200, { ok: true }); + }, + }, + { + method: 'GET', + path: '/api/me', + handler: async (req, res) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Не авторизован' }); + sendJson(res, 200, { username: s.ncUser, nextcloudUrl: NEXTCLOUD_URL }); + }, + }, + { + method: 'GET', + path: '/api/groups', + handler: async (req, res) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + try { + const data = await ocsRequest(s.ncUser, s.ncPass, 'GET', '/ocs/v1.php/cloud/groups', { + searchParams: { offset: 0, limit: 500 }, + }); + sendJson(res, 200, { groups: data.groups || [] }); + } catch (e) { + sendJson(res, 500, { error: e.message }); + } + }, + }, + { + method: 'GET', + path: '/api/users', + handler: async (req, res) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + try { + const list = await ocsRequest(s.ncUser, s.ncPass, 'GET', '/ocs/v1.php/cloud/users', { + searchParams: { offset: 0, limit: 500 }, + }); + const users = await Promise.all( + (list.users || []).map(async (id) => { + try { + const info = await ocsRequest(s.ncUser, s.ncPass, 'GET', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}`); + const groups = await ocsRequest(s.ncUser, s.ncPass, 'GET', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}/groups`); + const enabled = await ocsRequest(s.ncUser, s.ncPass, 'GET', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}/enabled`); + return { + id, + displayName: info.displayname || id, + email: info.email || '', + quota: info.quota?.quota || info.quota || 'default', + used: info.quota?.used || 0, + free: info.quota?.free || 0, + groups: groups.groups || [], + enabled: enabled === 1 || enabled === '1' || enabled === true, + }; + } catch { + return { id, displayName: id, email: '', quota: '?', groups: [], enabled: true }; + } + }) + ); + sendJson(res, 200, { users }); + } catch (e) { + sendJson(res, 500, { error: e.message }); + } + }, + }, + { + method: 'POST', + path: '/api/users', + handler: async (req, res) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + const { userid, password, quota, groups } = await readJsonBody(req); + if (!userid || !password) return sendJson(res, 400, { error: 'userid и password обязательны' }); + try { + await ocsRequest(s.ncUser, s.ncPass, 'POST', '/ocs/v1.php/cloud/users', { body: { userid, password } }); + if (quota) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(userid)}`, { + body: { key: 'quota', value: quota }, + }); + } + for (const g of groups || []) { + if (g) { + await ocsRequest(s.ncUser, s.ncPass, 'POST', `/ocs/v1.php/cloud/users/${encodeURIComponent(userid)}/groups`, { + body: { groupid: g }, + }); + } + } + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 400, { error: e.message }); + } + }, + }, + { + method: 'PATCH', + path: '/api/users/:id', + handler: async (req, res, params) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + const { quota, password, displayName, email, enabled } = await readJsonBody(req); + const id = params.id; + try { + if (quota !== undefined) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}`, { + body: { key: 'quota', value: quota }, + }); + } + if (password) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}`, { + body: { key: 'password', value: password }, + }); + } + if (displayName !== undefined) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}`, { + body: { key: 'displayname', value: displayName }, + }); + } + if (email !== undefined) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}`, { + body: { key: 'email', value: email }, + }); + } + if (enabled === false) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}/disable`); + } + if (enabled === true) { + await ocsRequest(s.ncUser, s.ncPass, 'PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(id)}/enable`); + } + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 400, { error: e.message }); + } + }, + }, + { + method: 'DELETE', + path: '/api/users/:id', + handler: async (req, res, params) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + try { + await ocsRequest(s.ncUser, s.ncPass, 'DELETE', `/ocs/v1.php/cloud/users/${encodeURIComponent(params.id)}`); + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 400, { error: e.message }); + } + }, + }, + { + method: 'POST', + path: '/api/users/:id/groups', + handler: async (req, res, params) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + const { groupid } = await readJsonBody(req); + if (!groupid) return sendJson(res, 400, { error: 'groupid обязателен' }); + try { + await ocsRequest(s.ncUser, s.ncPass, 'POST', + `/ocs/v1.php/cloud/users/${encodeURIComponent(params.id)}/groups`, { body: { groupid } }); + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 400, { error: e.message }); + } + }, + }, + { + method: 'DELETE', + path: '/api/users/:id/groups/:groupid', + handler: async (req, res, params) => { + const s = getSession(req); + if (!s?.ncUser) return sendJson(res, 401, { error: 'Требуется вход' }); + try { + await ocsRequest(s.ncUser, s.ncPass, 'DELETE', + `/ocs/v1.php/cloud/users/${encodeURIComponent(params.id)}/groups/${encodeURIComponent(params.groupid)}`); + sendJson(res, 200, { ok: true }); + } catch (e) { + sendJson(res, 400, { error: e.message }); + } + }, + }, +]; + +async function handleRequest(req, res) { + const ip = clientIp(req); + if (ALLOWED_IPS.length && !ALLOWED_IPS.includes(ip) && !ALLOWED_IPS.includes(req.socket?.remoteAddress)) { + return sendJson(res, 403, { error: 'Доступ запрещён с этого IP' }); + } + + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const matched = matchRoute(req.method, url.pathname, routes); + + if (matched) { + return matched.handler(req, res, matched.params); + } + + const file = await serveStatic(PUBLIC_DIR, url.pathname); + if (file) { + res.writeHead(200, { 'Content-Type': file.type, 'Content-Length': file.body.length }); + res.end(file.body); + return; + } + + sendJson(res, 404, { error: 'Not found' }); +} + +listen(handleRequest, { + host: HOST, + port: PORT, + http2: USE_HTTP2, + tls: TLS, +}); + +console.log(`Nextcloud API → ${NEXTCLOUD_URL}`); diff --git a/admin-ui/start.sh b/admin-ui/start.sh new file mode 100644 index 0000000..f0bc444 --- /dev/null +++ b/admin-ui/start.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Запуск Web UI управления пользователями Nextcloud +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +if [[ ! -f .env ]]; then + cp .env.example .env + # Подставить URL из docker/.env если есть + if [[ -f "${SCRIPT_DIR}/../docker/.env" ]]; then + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/../docker/.env" + PORT="${NEXTCLOUD_PORT:-8080}" + sed -i "s|^NEXTCLOUD_URL=.*|NEXTCLOUD_URL=http://127.0.0.1:${PORT}|" .env + fi + echo "[INFO] Создан .env — проверьте NEXTCLOUD_URL" +fi + +if ! command -v node &>/dev/null; then + echo "[ERROR] Node.js не установлен (нужен >= 18)" + exit 1 +fi + +# shellcheck disable=SC1091 +source .env +echo "[INFO] Admin UI: http://127.0.0.1:${ADMIN_UI_PORT:-3000}" +echo "[INFO] Nextcloud: ${NEXTCLOUD_URL}" +exec node server.js diff --git a/check-sudo.sh b/check-sudo.sh new file mode 100644 index 0000000..ff79e31 --- /dev/null +++ b/check-sudo.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Проверка корректной работы скриптов с sudo и без +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +ok() { echo " OK: $*"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); } + +echo "=== Синтаксис bash ===" +while IFS= read -r -d '' f; do + if bash -n "${f}" 2>/dev/null; then + ok "bash -n ${f#$ROOT/}" + else + fail "bash -n ${f#$ROOT/}" + fi +done < <(find "${ROOT}" -name '*.sh' -print0) + +echo "" +echo "=== Проверка sudo-поведения ===" + +# 1. Docker install: sudo должен переключиться на SUDO_USER +if sudo -u "$(id -un)" env SUDO_USER="$(id -un)" bash -c " + source '${ROOT}/lib/common.sh' + EUID=0 + SUDO_USER='$(id -un)' + drop_root_if_sudo() { + if [[ \"\${EUID}\" -eq 0 && -n \"\${SUDO_USER:-}\" && \"\${SUDO_USER}\" != root ]]; then + echo REEXEC_AS_\${SUDO_USER} + exit 0 + fi + } + drop_root_if_sudo +" 2>/dev/null | grep -q "REEXEC_AS_"; then + ok "drop_root_if_sudo переключает docker-скрипты на пользователя" +else + # Прямой тест через grep в common.sh + if grep -q 'drop_root_if_sudo' "${ROOT}/lib/common.sh"; then + ok "drop_root_if_sudo определён в lib/common.sh" + else + fail "drop_root_if_sudo" + fi +fi + +# 2. Native install: без root должен вызывать sudo +if grep -q 'exec sudo -E bash' "${ROOT}/native/install.sh"; then + ok "native/install.sh автоповышает права через sudo" +else + fail "native/install.sh автоповышение" +fi + +# 3. Native status: автоповышение +if grep -q 'exec sudo -E bash' "${ROOT}/native/status.sh"; then + ok "native/status.sh работает через sudo" +else + fail "native/status.sh sudo" +fi + +# 4. setup-env под sudo: docker +if grep -q 'drop_root_if_sudo' "${ROOT}/docker/setup-env.sh"; then + ok "docker/setup-env.sh отклоняет sudo" +else + fail "docker/setup-env.sh sudo" +fi + +# 5. run_occ определён +if grep -q 'run_occ()' "${ROOT}/lib/common.sh"; then + ok "run_occ() для occ под www-data" +else + fail "run_occ()" +fi + +# 6. Проверка sudo доступности +if command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then + ok "sudo доступен (NOPASSWD или активная сессия)" +elif command -v sudo &>/dev/null; then + ok "sudo установлен (потребует пароль при native/install.sh)" +else + fail "sudo не найден — native-установка не сработает" +fi + +# 7. Автоустановка Docker +if [[ -f "${ROOT}/docker/install-docker.sh" ]] && grep -q 'ensure_docker' "${ROOT}/lib/common.sh"; then + ok "install-docker.sh + ensure_docker() для автоустановки" +else + fail "автоустановка Docker" +fi + +# 8. Docker без sudo +if command -v docker &>/dev/null; then + if docker info &>/dev/null; then + ok "docker доступен без sudo" + else + echo " WARN: docker есть, но недоступен без sudo — добавьте пользователя в группу docker" + fi +else + echo " INFO: docker не установлен — install.sh предложит автоустановку" +fi + +echo "" +echo "=== Итог: ${PASS} OK, ${FAIL} FAIL ===" +[[ "${FAIL}" -eq 0 ]] diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..d3f1012 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,33 @@ +# Скопируйте: cp .env.example .env или запустите ./setup-env.sh + +NEXTCLOUD_VERSION=apache +NEXTCLOUD_PORT=8080 +# HTTPS внутри Docker (./enable-docker-ssl.sh), по умолчанию 8443 +# NEXTCLOUD_HTTPS_PORT=8443 +# SSL_FULLCHAIN=./certs/fullchain.pem +# SSL_PRIVKEY=./certs/privkey.pem + +# Пробелы — только в двойных кавычках! +NEXTCLOUD_TRUSTED_DOMAINS="localhost 127.0.0.1" + +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=CHANGE_ME_admin_password + +MYSQL_ROOT_PASSWORD=CHANGE_ME_root_password +MYSQL_DATABASE=nextcloud +MYSQL_USER=nextcloud +MYSQL_PASSWORD=CHANGE_ME_db_password + +REDIS_PASSWORD=CHANGE_ME_redis_password + +PHP_MEMORY_LIMIT=512M +PHP_UPLOAD_LIMIT=10G + +NEXTCLOUD_INSTANCEID=CHANGE_ME_instanceid + +# Все пути — ВНЕ nextcloud_install (задаются при ./setup-env.sh) +NEXTCLOUD_CONFIG_DIR=/var/nextcloud/config +NEXTCLOUD_DATADIR=/mnt/hgfs/nextcloud_data +NEXTCLOUD_APPDATA=/var/nextcloud/appdata +NEXTCLOUD_CUSTOM_APPS=/var/nextcloud/custom_apps +NEXTCLOUD_BACKUP_DIR=/var/nextcloud/backups diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..92ee1d7 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,6 @@ +certs/*.pem +certs/*.key +.ssl-enabled +.env +data/ +backups/ diff --git a/docker/Dockerfile.ssl b/docker/Dockerfile.ssl new file mode 100644 index 0000000..ad83aa0 --- /dev/null +++ b/docker/Dockerfile.ssl @@ -0,0 +1,10 @@ +# Образ Nextcloud + Apache с включённым SSL (mod_ssl) +FROM nextcloud:${NEXTCLOUD_BASE_TAG:-apache} + +RUN a2enmod ssl headers \ + && cp /etc/apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-default-http.conf.bak + +COPY templates/apache-ssl-inside.conf /etc/apache2/sites-available/nextcloud-ssl.conf +RUN a2ensite nextcloud-ssl + +EXPOSE 80 443 diff --git a/docker/add-user.sh b/docker/add-user.sh new file mode 100644 index 0000000..25844b0 --- /dev/null +++ b/docker/add-user.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Интерактивное создание пользователя Nextcloud (Docker) +# +# Использование: +# ./add-user.sh +# ./add-user.sh bob 'SecretPass' 10GB +# ./add-user.sh bob 'SecretPass' none admin,users + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/add-user.sh" "$@" + +[[ -f .env ]] || { echo "[ERROR] Сначала: ./install.sh"; exit 1; } +check_docker_access || exit 1 + +USER_NAME="${1:-}" +USER_PASS="${2:-}" +USER_QUOTA="${3:-}" +USER_GROUPS="${4:-}" + +prompt() { + local var_name="$1" + local prompt_text="$2" + local secret="${3:-false}" + local value="" + + if [[ -n "${!var_name:-}" ]]; then + return 0 + fi + + if [[ "${secret}" == true ]]; then + read -r -s -p "${prompt_text}: " value + echo "" + else + read -r -p "${prompt_text}: " value + fi + printf -v "${var_name}" '%s' "${value}" +} + +prompt USER_NAME "Логин нового пользователя" +prompt USER_PASS "Пароль" true +prompt USER_QUOTA "Квота (none / 1GB / 5GB / 10GB / 100GB) [default]" +prompt USER_GROUPS "Группы через запятую (пусто = только users)" + +[[ -n "${USER_NAME}" && -n "${USER_PASS}" ]] || { + echo "[ERROR] Логин и пароль обязательны" + exit 1 +} + +if run_compose exec -T -u www-data app php occ user:list | grep -q "^ - ${USER_NAME}$"; then + echo "[ERROR] Пользователь '${USER_NAME}' уже существует" + exit 1 +fi + +echo "[INFO] Создание пользователя ${USER_NAME}..." +export OC_PASS="${USER_PASS}" +run_compose exec -T -u www-data -e OC_PASS app php occ user:add "${USER_NAME}" --password-from-env +unset OC_PASS + +QUOTA="${USER_QUOTA:-default}" +run_compose exec -T -u www-data app php occ user:setting "${USER_NAME}" files quota "${QUOTA}" + +IFS=',' read -ra GROUPS <<< "${USER_GROUPS}" +for g in "${GROUPS[@]}"; do + g="$(echo "${g}" | xargs)" + [[ -z "${g}" ]] && continue + run_compose exec -T -u www-data app php occ group:adduser "${g}" "${USER_NAME}" 2>/dev/null \ + || echo "[WARN] Не удалось добавить в группу: ${g}" +done + +# Группа users по умолчанию +run_compose exec -T -u www-data app php occ group:adduser users "${USER_NAME}" 2>/dev/null || true + +echo "" +echo "[INFO] Пользователь создан:" +echo " Логин: ${USER_NAME}" +echo " Квота: ${QUOTA}" +[[ -n "${USER_GROUPS}" ]] && echo " Группы: ${USER_GROUPS}, users" +echo "" +run_compose exec -T -u www-data app php occ user:info "${USER_NAME}" 2>/dev/null || true diff --git a/docker/backup.sh b/docker/backup.sh new file mode 100644 index 0000000..7ef28ba --- /dev/null +++ b/docker/backup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/backup.sh" "$@" +[[ -f .env ]] || exit 1 +# shellcheck disable=SC1091 +source .env +check_docker_access || exit 1 + +BACKUP_ROOT="${NEXTCLOUD_BACKUP_DIR:-/var/nextcloud/backups}" +ensure_host_dir "${BACKUP_ROOT}" +sudo chown "$(id -u):$(id -g)" "${BACKUP_ROOT}" 2>/dev/null || true +sudo chmod 0775 "${BACKUP_ROOT}" 2>/dev/null || true + +TS="$(date +%Y-%m-%d_%H-%M-%S)" +BD="${BACKUP_ROOT}/backup_${TS}" +ARCH="${BACKUP_ROOT}/nextcloud_docker_${TS}.tar.gz" +mkdir -p "${BD}" + +run_compose exec -T app php occ maintenance:mode --on 2>/dev/null || true +run_compose exec -T db mariadb-dump -u root -p"${MYSQL_ROOT_PASSWORD}" \ + --single-transaction "${MYSQL_DATABASE}" > "${BD}/database.sql" +if [[ -d "${NEXTCLOUD_CONFIG_DIR:-}" ]]; then + cp -a "${NEXTCLOUD_CONFIG_DIR}" "${BD}/config" 2>/dev/null || sudo cp -a "${NEXTCLOUD_CONFIG_DIR}" "${BD}/config" +fi +if [[ -d "${NEXTCLOUD_APPDATA:-}" ]]; then + tar czf "${BD}/host_appdata.tar.gz" -C "${NEXTCLOUD_APPDATA}" . 2>/dev/null || \ + sudo tar czf "${BD}/host_appdata.tar.gz" -C "${NEXTCLOUD_APPDATA}" . +fi +if [[ -d "${NEXTCLOUD_CUSTOM_APPS:-}" ]]; then + tar czf "${BD}/host_custom_apps.tar.gz" -C "${NEXTCLOUD_CUSTOM_APPS}" . 2>/dev/null || \ + sudo tar czf "${BD}/host_custom_apps.tar.gz" -C "${NEXTCLOUD_CUSTOM_APPS}" . +fi +if [[ -d "${NEXTCLOUD_DATADIR:-}" ]]; then + tar czf "${BD}/host_userdata.tar.gz" -C "${NEXTCLOUD_DATADIR}" \ + --exclude='appdata_*' --exclude='nextcloud.log' . 2>/dev/null || \ + sudo tar czf "${BD}/host_userdata.tar.gz" -C "${NEXTCLOUD_DATADIR}" \ + --exclude='appdata_*' --exclude='nextcloud.log' . +fi +run_docker run --rm -v nextcloud_app_data:/data:ro -v "${BD}:/backup" alpine:3.20 \ + tar czf /backup/nextcloud_data.tar.gz -C /data . +run_compose exec -T app php occ maintenance:mode --off 2>/dev/null || true + +tar czf "${ARCH}" -C "${BACKUP_ROOT}" "backup_${TS}" +rm -rf "${BD}" +echo "[INFO] Бэкап: ${ARCH}" diff --git a/docker/certs/README.txt b/docker/certs/README.txt new file mode 100644 index 0000000..08bcf0d --- /dev/null +++ b/docker/certs/README.txt @@ -0,0 +1,3 @@ +# Положите сюда сертификаты (не коммитьте в git): +# fullchain.pem +# privkey.pem diff --git a/docker/config.defaults/.htaccess b/docker/config.defaults/.htaccess new file mode 100644 index 0000000..1db45aa --- /dev/null +++ b/docker/config.defaults/.htaccess @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-FileCopyrightText: 2014-2015 ownCloud, Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# Section for Apache 2.4 to 2.6 + + Require all denied + + + Order Allow,Deny + Deny from all + Satisfy All + + +# Section for Apache 2.2 + + + + Order Allow,Deny + Deny from all + + Satisfy All + + + +# Section for Apache 2.2 to 2.6 + + IndexIgnore * + diff --git a/docker/config.defaults/apache-pretty-urls.config.php b/docker/config.defaults/apache-pretty-urls.config.php new file mode 100644 index 0000000..72da1d8 --- /dev/null +++ b/docker/config.defaults/apache-pretty-urls.config.php @@ -0,0 +1,4 @@ + '/', +); diff --git a/docker/config.defaults/apcu.config.php b/docker/config.defaults/apcu.config.php new file mode 100644 index 0000000..69fed87 --- /dev/null +++ b/docker/config.defaults/apcu.config.php @@ -0,0 +1,4 @@ + '\OC\Memcache\APCu', +); diff --git a/docker/config.defaults/apps.config.php b/docker/config.defaults/apps.config.php new file mode 100644 index 0000000..4c37f72 --- /dev/null +++ b/docker/config.defaults/apps.config.php @@ -0,0 +1,15 @@ + array ( + 0 => array ( + 'path' => OC::$SERVERROOT.'/apps', + 'url' => '/apps', + 'writable' => false, + ), + 1 => array ( + 'path' => OC::$SERVERROOT.'/custom_apps', + 'url' => '/custom_apps', + 'writable' => true, + ), + ), +); diff --git a/docker/config.defaults/autoconfig.php b/docker/config.defaults/autoconfig.php new file mode 100644 index 0000000..92ad2a1 --- /dev/null +++ b/docker/config.defaults/autoconfig.php @@ -0,0 +1,41 @@ + '\OC\Memcache\Redis', + 'memcache.locking' => '\OC\Memcache\Redis', + 'redis' => array( + 'host' => getenv('REDIS_HOST'), + 'password' => getenv('REDIS_HOST_PASSWORD_FILE') ? trim(file_get_contents(getenv('REDIS_HOST_PASSWORD_FILE'))) : (string) getenv('REDIS_HOST_PASSWORD'), + ), + ); + + if (getenv('REDIS_HOST_PORT') !== false) { + $CONFIG['redis']['port'] = (int) getenv('REDIS_HOST_PORT'); + } elseif (getenv('REDIS_HOST')[0] != '/') { + $CONFIG['redis']['port'] = 6379; + } + + if (getenv('REDIS_HOST_USER') !== false) { + $CONFIG['redis']['user'] = (string) getenv('REDIS_HOST_USER'); + } +} diff --git a/docker/config.defaults/reverse-proxy.config.php b/docker/config.defaults/reverse-proxy.config.php new file mode 100644 index 0000000..30c660f --- /dev/null +++ b/docker/config.defaults/reverse-proxy.config.php @@ -0,0 +1,35 @@ + array( + 'class' => '\OC\Files\ObjectStore\S3', + 'arguments' => array( + 'bucket' => getenv('OBJECTSTORE_S3_BUCKET'), + 'region' => getenv('OBJECTSTORE_S3_REGION') ?: '', + 'hostname' => getenv('OBJECTSTORE_S3_HOST') ?: '', + 'port' => getenv('OBJECTSTORE_S3_PORT') ?: '', + 'storageClass' => getenv('OBJECTSTORE_S3_STORAGE_CLASS') ?: '', + 'objectPrefix' => getenv("OBJECTSTORE_S3_OBJECT_PREFIX") ? getenv("OBJECTSTORE_S3_OBJECT_PREFIX") : "urn:oid:", + 'autocreate' => strtolower($autocreate) !== 'false', + 'use_ssl' => strtolower($use_ssl) !== 'false', + // required for some non Amazon S3 implementations + 'use_path_style' => $use_path == true && strtolower($use_path) !== 'false', + // required for older protocol versions + 'legacy_auth' => $use_legacyauth == true && strtolower($use_legacyauth) !== 'false' + ) + ) + ); + + if (getenv('OBJECTSTORE_S3_KEY_FILE')) { + $CONFIG['objectstore']['arguments']['key'] = trim(file_get_contents(getenv('OBJECTSTORE_S3_KEY_FILE'))); + } elseif (getenv('OBJECTSTORE_S3_KEY')) { + $CONFIG['objectstore']['arguments']['key'] = getenv('OBJECTSTORE_S3_KEY'); + } else { + $CONFIG['objectstore']['arguments']['key'] = ''; + } + + if (getenv('OBJECTSTORE_S3_SECRET_FILE')) { + $CONFIG['objectstore']['arguments']['secret'] = trim(file_get_contents(getenv('OBJECTSTORE_S3_SECRET_FILE'))); + } elseif (getenv('OBJECTSTORE_S3_SECRET')) { + $CONFIG['objectstore']['arguments']['secret'] = getenv('OBJECTSTORE_S3_SECRET'); + } else { + $CONFIG['objectstore']['arguments']['secret'] = ''; + } + + if (getenv('OBJECTSTORE_S3_SSE_C_KEY_FILE')) { + $CONFIG['objectstore']['arguments']['sse_c_key'] = trim(file_get_contents(getenv('OBJECTSTORE_S3_SSE_C_KEY_FILE'))); + } elseif (getenv('OBJECTSTORE_S3_SSE_C_KEY')) { + $CONFIG['objectstore']['arguments']['sse_c_key'] = getenv('OBJECTSTORE_S3_SSE_C_KEY'); + } +} diff --git a/docker/config.defaults/smtp.config.php b/docker/config.defaults/smtp.config.php new file mode 100644 index 0000000..66a2ef7 --- /dev/null +++ b/docker/config.defaults/smtp.config.php @@ -0,0 +1,22 @@ + 'smtp', + 'mail_smtphost' => getenv('SMTP_HOST'), + 'mail_smtpport' => getenv('SMTP_PORT') ?: (getenv('SMTP_SECURE') ? 465 : 25), + 'mail_smtpsecure' => getenv('SMTP_SECURE') ?: '', + 'mail_smtpauth' => getenv('SMTP_NAME') && (getenv('SMTP_PASSWORD') || getenv('SMTP_PASSWORD_FILE')), + 'mail_smtpauthtype' => getenv('SMTP_AUTHTYPE') ?: 'LOGIN', + 'mail_smtpname' => getenv('SMTP_NAME') ?: '', + 'mail_from_address' => getenv('MAIL_FROM_ADDRESS'), + 'mail_domain' => getenv('MAIL_DOMAIN'), + ); + + if (getenv('SMTP_PASSWORD_FILE')) { + $CONFIG['mail_smtppassword'] = trim(file_get_contents(getenv('SMTP_PASSWORD_FILE'))); + } elseif (getenv('SMTP_PASSWORD')) { + $CONFIG['mail_smtppassword'] = getenv('SMTP_PASSWORD'); + } else { + $CONFIG['mail_smtppassword'] = ''; + } +} diff --git a/docker/config.defaults/swift.config.php b/docker/config.defaults/swift.config.php new file mode 100644 index 0000000..47ada56 --- /dev/null +++ b/docker/config.defaults/swift.config.php @@ -0,0 +1,31 @@ + [ + 'class' => 'OC\\Files\\ObjectStore\\Swift', + 'arguments' => [ + 'autocreate' => $autocreate == true && strtolower($autocreate) !== 'false', + 'user' => [ + 'name' => getenv('OBJECTSTORE_SWIFT_USER_NAME'), + 'password' => getenv('OBJECTSTORE_SWIFT_USER_PASSWORD'), + 'domain' => [ + 'name' => (getenv('OBJECTSTORE_SWIFT_USER_DOMAIN')) ?: 'Default', + ], + ], + 'scope' => [ + 'project' => [ + 'name' => getenv('OBJECTSTORE_SWIFT_PROJECT_NAME'), + 'domain' => [ + 'name' => (getenv('OBJECTSTORE_SWIFT_PROJECT_DOMAIN')) ?: 'Default', + ], + ], + ], + 'serviceName' => (getenv('OBJECTSTORE_SWIFT_SERVICE_NAME')) ?: 'swift', + 'region' => getenv('OBJECTSTORE_SWIFT_REGION'), + 'url' => getenv('OBJECTSTORE_SWIFT_URL'), + 'bucket' => getenv('OBJECTSTORE_SWIFT_CONTAINER_NAME'), + ] + ] + ); +} diff --git a/docker/config.defaults/upgrade-disable-web.config.php b/docker/config.defaults/upgrade-disable-web.config.php new file mode 100644 index 0000000..cb00b43 --- /dev/null +++ b/docker/config.defaults/upgrade-disable-web.config.php @@ -0,0 +1,4 @@ + true, +); diff --git a/docker/disable-docker-ssl.sh b/docker/disable-docker-ssl.sh new file mode 100644 index 0000000..a148dee --- /dev/null +++ b/docker/disable-docker-ssl.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Отключить HTTPS внутри Docker, вернуть стандartный образ nextcloud:apache +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/disable-docker-ssl.sh" "$@" + +[[ -f .ssl-enabled ]] || { echo "[INFO] SSL в Docker не был включён"; exit 0; } + +rm -f .ssl-enabled + +echo "[INFO] Пересборка без SSL overlay..." +run_compose up -d --build --no-deps app + +# shellcheck disable=SC1091 +source .env 2>/dev/null || true +HTTP_URL="http://127.0.0.1:${NEXTCLOUD_PORT:-8080}" + +echo "[INFO] Сброс URL Nextcloud на HTTP..." +run_compose exec -T -u www-data app php occ config:system:delete overwriteprotocol 2>/dev/null || true +run_compose exec -T -u www-data app php occ config:system:delete trusted_proxies 2>/dev/null || true +run_compose exec -T -u www-data app php occ config:system:set overwrite.cli.url --value="${HTTP_URL}" 2>/dev/null || true + +echo "[INFO] HTTPS отключён. Доступ: ${HTTP_URL}" diff --git a/docker/docker-compose.ssl.yml b/docker/docker-compose.ssl.yml new file mode 100644 index 0000000..2197ea2 --- /dev/null +++ b/docker/docker-compose.ssl.yml @@ -0,0 +1,16 @@ +# Overlay: HTTPS внутри контейнера (сертификаты с хоста) +# Использование: docker compose -f docker-compose.yml -f docker-compose.ssl.yml up -d +# Или: ./enable-docker-ssl.sh + +services: + app: + build: + context: . + dockerfile: Dockerfile.ssl + args: + NEXTCLOUD_BASE_TAG: ${NEXTCLOUD_VERSION:-apache} + ports: + - "${NEXTCLOUD_HTTPS_PORT:-8443}:443" + volumes: + - ${SSL_FULLCHAIN:-./certs/fullchain.pem}:/etc/ssl/nextcloud/fullchain.pem:ro + - ${SSL_PRIVKEY:-./certs/privkey.pem}:/etc/ssl/nextcloud/privkey.pem:ro diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..6748b0c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,114 @@ +# Docker Compose стек Nextcloud (серверная часть) +# Состав: MariaDB + Redis + Nextcloud (Apache) + cron-задачи +# +# Запуск: ./install.sh или docker compose up -d +# Остановка: ./stop.sh или docker compose down + +services: + # База данных MariaDB — хранит метаданные файлов, пользователей, настроек + db: + image: mariadb:11.4 + container_name: nextcloud-db + restart: unless-stopped + command: > + --transaction-isolation=READ-COMMITTED + --log-bin=binlog + --binlog-format=ROW + --innodb-read-only-compressed=OFF + volumes: + - db_data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + networks: + - nextcloud_internal + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # Redis — кэширование и блокировки (существенно ускоряет Nextcloud) + redis: + image: redis:7-alpine + container_name: nextcloud-redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + networks: + - nextcloud_internal + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 3s + retries: 5 + + # Основной контейнер Nextcloud (веб-сервер Apache + PHP) + app: + image: nextcloud:${NEXTCLOUD_VERSION:-apache} + container_name: nextcloud-app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "${NEXTCLOUD_PORT:-8080}:80" + volumes: + - nextcloud_data:/var/www/html + - ${NEXTCLOUD_CONFIG_DIR}:/var/www/html/config + # Файлы пользователей (hgfs / большой диск) + - ${NEXTCLOUD_DATADIR:-/mnt/hgfs/nextcloud_data}:/var/www/html/data + # appdata (кэш App Store, превью) — локальный диск, не hgfs + - ${NEXTCLOUD_APPDATA:-/var/nextcloud/appdata}:/var/www/html/data/appdata_${NEXTCLOUD_INSTANCEID} + # приложения из магазина — локальный диск + - ${NEXTCLOUD_CUSTOM_APPS:-/var/nextcloud/custom_apps}:/var/www/html/custom_apps + environment: + MYSQL_HOST: db + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + REDIS_HOST: redis + REDIS_HOST_PASSWORD: ${REDIS_PASSWORD} + NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER} + NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD} + NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS} + PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M} + PHP_UPLOAD_LIMIT: ${PHP_UPLOAD_LIMIT:-10G} + networks: + - nextcloud_internal + + # Фоновые задачи Nextcloud (cron) + cron: + image: nextcloud:${NEXTCLOUD_VERSION:-apache} + container_name: nextcloud-cron + restart: unless-stopped + depends_on: + - app + volumes: + - nextcloud_data:/var/www/html + - ${NEXTCLOUD_CONFIG_DIR}:/var/www/html/config + - ${NEXTCLOUD_DATADIR:-/mnt/hgfs/nextcloud_data}:/var/www/html/data + - ${NEXTCLOUD_APPDATA:-/var/nextcloud/appdata}:/var/www/html/data/appdata_${NEXTCLOUD_INSTANCEID} + - ${NEXTCLOUD_CUSTOM_APPS:-/var/nextcloud/custom_apps}:/var/www/html/custom_apps + entrypoint: /cron.sh + networks: + - nextcloud_internal + +volumes: + db_data: + name: nextcloud_db_data + redis_data: + name: nextcloud_redis_data + nextcloud_data: + name: nextcloud_app_data + +networks: + nextcloud_internal: + name: nextcloud_internal + driver: bridge diff --git a/docker/enable-docker-ssl.sh b/docker/enable-docker-ssl.sh new file mode 100644 index 0000000..0dfcbbb --- /dev/null +++ b/docker/enable-docker-ssl.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# HTTPS внутри Docker: монтирование сертификата в контейнер (Apache + mod_ssl) +# +# Использование: +# ./enable-docker-ssl.sh cloud.example.com \ +# /path/to/fullchain.pem /path/to/privkey.pem +# +# Или положите файлы в docker/certs/ и запустите: +# ./enable-docker-ssl.sh cloud.example.com +# +# Nextcloud будет доступен: https://cloud.example.com:8443 (порт NEXTCLOUD_HTTPS_PORT) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/enable-docker-ssl.sh" "$@" + +[[ -f .env ]] || { echo "[ERROR] Сначала: ./install.sh"; exit 1; } +check_docker_access || exit 1 + +# shellcheck disable=SC1091 +source .env + +DOMAIN="${1:-}" +SRC_CERT="${2:-}" +SRC_KEY="${3:-}" + +CERT_DIR="${SCRIPT_DIR}/certs" +CERT_FILE="${CERT_DIR}/fullchain.pem" +KEY_FILE="${CERT_DIR}/privkey.pem" + +mkdir -p "${CERT_DIR}" + +if [[ -z "${DOMAIN}" ]]; then + read -r -p "Домен (trusted domain): " DOMAIN +fi + +# Копируем или используем certs/ +if [[ -n "${SRC_CERT}" && -n "${SRC_KEY}" ]]; then + cp "${SRC_CERT}" "${CERT_FILE}" + cp "${SRC_KEY}" "${KEY_FILE}" +elif [[ ! -f "${CERT_FILE}" || ! -f "${KEY_FILE}" ]]; then + echo "[ERROR] Укажите сертификаты или положите в ${CERT_DIR}/" + echo " ./enable-docker-ssl.sh domain /path/fullchain.pem /path/privkey.pem" + exit 1 +fi + +chmod 600 "${KEY_FILE}" 2>/dev/null || true + +# Порт HTTPS (по умолчанию 8443) +HTTPS_PORT="${NEXTCLOUD_HTTPS_PORT:-8443}" +if ! grep -q '^NEXTCLOUD_HTTPS_PORT=' .env 2>/dev/null; then + echo "NEXTCLOUD_HTTPS_PORT=${HTTPS_PORT}" >> .env +fi + +touch .ssl-enabled + +echo "[INFO] Сборка образа с mod_ssl..." +run_compose build app + +echo "[INFO] Перезапуск с HTTPS (порт ${HTTPS_PORT})..." +run_compose up -d +ensure_app_code_ready + +HTTPS_URL="https://${DOMAIN}:${HTTPS_PORT}" + +echo "[INFO] Настройка Nextcloud..." +if run_compose exec -T -u www-data app php occ status &>/dev/null; then + run_compose exec -T -u www-data app php occ config:system:set overwrite.cli.url --value="${HTTPS_URL}" + run_compose exec -T -u www-data app php occ config:system:set overwriteprotocol --value="https" + # Порт в overwritehost обязателен для публичных ссылок /s/… при нестандартном HTTPS-порте + # run_compose exec -T -u www-data app php occ config:system:set overwritehost --value="${DOMAIN}:${HTTPS_PORT}" +else + echo "[WARN] Nextcloud ещё не установлен; после install.sh выполните:" + echo " ./enable-docker-ssl.sh ${DOMAIN}" +fi + +current_domains="$(grep '^NEXTCLOUD_TRUSTED_DOMAINS=' .env | cut -d= -f2- | sed 's/^"//; s/"$//' || true)" +if ! printf '%s\n' "${current_domains}" | tr ' ' '\n' | grep -Fxq "${DOMAIN}"; then + current_domains="${current_domains} ${DOMAIN}" + current_domains="$(echo "${current_domains}" | xargs)" + sed -i "s|^NEXTCLOUD_TRUSTED_DOMAINS=.*|NEXTCLOUD_TRUSTED_DOMAINS=\"${current_domains}\"|" .env +fi + +echo "" +echo "[INFO] === HTTPS внутри Docker включён ===" +echo " URL: ${HTTPS_URL}" +echo " HTTP: http://127.0.0.1:${NEXTCLOUD_PORT:-8080} (по-прежнему работает)" +echo " Certs: ${CERT_DIR}/ → /etc/ssl/nextcloud/ в контейнере" +echo "" +echo "Отключить: ./disable-docker-ssl.sh" +echo "Проверка: curl -kI ${HTTPS_URL}/status.php" diff --git a/docker/install-docker.sh b/docker/install-docker.sh new file mode 100644 index 0000000..03b0906 --- /dev/null +++ b/docker/install-docker.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Установка Docker Engine + Compose v2 из официального репозитория +# Запуск: sudo ./install-docker.sh [имя_пользователя_для_группы_docker] +# +# Поддерживается: Ubuntu, Debian +# Документация: https://docs.docker.com/engine/install/ubuntu/ + +set -euo pipefail + +TARGET_USER="${1:-${SUDO_USER:-$(logname 2>/dev/null || echo root)}}" + +log_info() { echo "[INFO] $*"; } +log_error() { echo "[ERROR] $*" >&2; } + +[[ "${EUID}" -eq 0 ]] || { log_error "Запустите: sudo ./install-docker.sh"; exit 1; } +[[ -f /etc/os-release ]] || { log_error "Неизвестная ОС"; exit 1; } + +# shellcheck disable=SC1091 +source /etc/os-release + +case "${ID}" in + ubuntu|debian) + DOCKER_REPO="https://download.docker.com/linux/${ID}" + ;; + *) + log_error "Автоустановка поддерживает только Ubuntu/Debian (сейчас: ${ID})" + log_error "Установите Docker вручную: https://docs.docker.com/engine/install/" + exit 1 + ;; +esac + +# Уже установлен? +if command -v docker &>/dev/null && docker compose version &>/dev/null 2>&1; then + log_info "Docker уже установлен: $(docker --version)" + if id -nG "${TARGET_USER}" 2>/dev/null | grep -qw docker; then + log_info "Пользователь ${TARGET_USER} уже в группе docker" + else + log_info "Добавляем ${TARGET_USER} в группу docker..." + usermod -aG docker "${TARGET_USER}" + log_info "Перелогиньтесь или выполните: newgrp docker" + fi + exit 0 +fi + +log_info "Установка Docker для ${PRETTY_NAME}..." + +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq ca-certificates curl gnupg + +install -m 0755 -d /etc/apt/keyrings +curl -fsSL "${DOCKER_REPO}/gpg" -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] ${DOCKER_REPO} \ + ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + +apt-get update -qq +apt-get install -y -qq \ + docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +systemctl enable --now docker + +if [[ "${TARGET_USER}" != root ]]; then + usermod -aG docker "${TARGET_USER}" + log_info "Пользователь ${TARGET_USER} добавлен в группу docker" +fi + +log_info "Docker установлен: $(docker --version)" +log_info "Compose: $(docker compose version)" + +if [[ "${TARGET_USER}" != root ]]; then + log_info "Если docker без sudo не работает — перелогиньтесь или: newgrp docker" +fi diff --git a/docker/install.sh b/docker/install.sh new file mode 100644 index 0000000..b2e921a --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Развёртывание Nextcloud через Docker Compose +# Запускать БЕЗ sudo: ./install.sh [домен_или_ip] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +drop_root_if_sudo "${SCRIPT_DIR}/install.sh" "$@" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +DOMAIN="" +DOCKER_AUTO="prompt" + +prompt_path() { + local label="$1" + local default="$2" + local value="" + read -r -p "${label} [${default}]: " value + echo "${value:-$default}" +} + +parse_args() { + local args=() + while [[ $# -gt 0 ]]; do + case "$1" in + --install-docker) DOCKER_AUTO="yes"; shift ;; + --skip-docker) DOCKER_AUTO="skip"; shift ;; + -h|--help) + echo "Использование: ./install.sh [--install-docker|--skip-docker] [домен]" + exit 0 + ;; + *) args+=("$1"); shift ;; + esac + done + DOMAIN="${args[0]:-}" +} + +ensure_docker_ready() { + ensure_docker "${SCRIPT_DIR}/install-docker.sh" "${DOCKER_AUTO}" || { + log_error "Docker недоступен. Установите вручную:" + log_error " sudo ./install-docker.sh $(id -un)" + exit 1 + } +} + +prompt_storage_paths() { + echo "" + echo "=== Пути хранения (вне nextcloud_install) ===" + CONFIG_DIR="$(prompt_path "Конфиг Nextcloud (config.php)" "${NEXTCLOUD_CONFIG_DIR:-/var/nextcloud/config}")" + DATADIR="$(prompt_path "Файлы пользователей" "${NEXTCLOUD_DATADIR:-/mnt/hgfs/nextcloud_data}")" + APPDATA="$(prompt_path "appdata (App Store, превью)" "${NEXTCLOUD_APPDATA:-/var/nextcloud/appdata}")" + CUSTOM_APPS="$(prompt_path "Приложения из магазина" "${NEXTCLOUD_CUSTOM_APPS:-/var/nextcloud/custom_apps}")" + BACKUP_DIR="$(prompt_path "Каталог бэкапов" "${NEXTCLOUD_BACKUP_DIR:-/var/nextcloud/backups}")" + + sed -i "s|^NEXTCLOUD_CONFIG_DIR=.*|NEXTCLOUD_CONFIG_DIR=${CONFIG_DIR}|" .env + sed -i "s|^NEXTCLOUD_DATADIR=.*|NEXTCLOUD_DATADIR=${DATADIR}|" .env + sed -i "s|^NEXTCLOUD_APPDATA=.*|NEXTCLOUD_APPDATA=${APPDATA}|" .env + sed -i "s|^NEXTCLOUD_CUSTOM_APPS=.*|NEXTCLOUD_CUSTOM_APPS=${CUSTOM_APPS}|" .env + sed -i "s|^NEXTCLOUD_BACKUP_DIR=.*|NEXTCLOUD_BACKUP_DIR=${BACKUP_DIR}|" .env + # shellcheck disable=SC1091 + source .env +} + +prepare_env() { + if [[ ! -f ".env" ]]; then + bash "${SCRIPT_DIR}/setup-env.sh" "${DOMAIN}" + fi + grep -q 'CHANGE_ME' .env && { log_error "В .env остались CHANGE_ME — пересоздайте: rm .env && ./setup-env.sh"; exit 1; } + # shellcheck disable=SC1091 + source .env + if [[ -z "${NEXTCLOUD_INSTANCEID:-}" ]]; then + NEXTCLOUD_INSTANCEID="$(openssl rand -base64 48 | tr -dc 'a-z0-9' | head -c 12)" + echo "NEXTCLOUD_INSTANCEID=${NEXTCLOUD_INSTANCEID}" >> .env + log_warn "Добавлен NEXTCLOUD_INSTANCEID в .env" + fi + if [[ -t 0 ]]; then + prompt_storage_paths + elif [[ -z "${NEXTCLOUD_CONFIG_DIR:-}" ]]; then + log_error "Задайте NEXTCLOUD_CONFIG_DIR в .env или запустите ./install.sh в интерактивном терминале" + exit 1 + fi +} + +config_write() { + config_write_file "$1" +} + +restore_config_defaults() { + local src="${SCRIPT_DIR}/config.defaults" + local cfg="${NEXTCLOUD_CONFIG_DIR}" + if [[ ! -d "${src}" ]]; then + log_warn "Нет ${src}/ — пропуск шаблонов config" + return 0 + fi + log_info "Проверка config для установки/переустановки..." + prepare_config_for_reinstall "${src}" "${cfg}" "${NEXTCLOUD_INSTANCEID}" +} + +ensure_instanceid_config() { + # shellcheck disable=SC1091 + source .env + local cfg="${NEXTCLOUD_CONFIG_DIR}" + config_write_file "${cfg}/instanceid.config.php" < '${NEXTCLOUD_INSTANCEID}', +); +EOF + config_write_file "${cfg}/datadir.config.php" <<'EOF' + false, +); +EOF + fix_config_permissions "${cfg}" + log_info "instanceid → ${NEXTCLOUD_INSTANCEID}" +} + +prepare_host_dirs() { + # shellcheck disable=SC1091 + source .env + local dirs=( + "${NEXTCLOUD_CONFIG_DIR}" + "${NEXTCLOUD_DATADIR}" + "${NEXTCLOUD_APPDATA}" + "${NEXTCLOUD_CUSTOM_APPS}" + ) + + for d in "${dirs[@]}"; do + if [[ ! -d "${d}" ]]; then + log_info "Создание ${d}" + fi + ensure_host_dir "${d}" + chown_wwwdata_dir "${d}" + done + + if [[ ! -d "${NEXTCLOUD_BACKUP_DIR}" ]]; then + log_info "Создание ${NEXTCLOUD_BACKUP_DIR}" + fi + ensure_host_dir "${NEXTCLOUD_BACKUP_DIR}" + sudo chown "$(id -u):$(id -g)" "${NEXTCLOUD_BACKUP_DIR}" + sudo chmod 0775 "${NEXTCLOUD_BACKUP_DIR}" + + sudo touch "${NEXTCLOUD_DATADIR}/.ncdata" 2>/dev/null || true + sudo chown 33:33 "${NEXTCLOUD_DATADIR}/.ncdata" 2>/dev/null || true + + log_info "Хранилище (вне nextcloud_install):" + echo " config → ${NEXTCLOUD_CONFIG_DIR}" + echo " data → ${NEXTCLOUD_DATADIR}" + echo " appdata → ${NEXTCLOUD_APPDATA} (appdata_${NEXTCLOUD_INSTANCEID})" + echo " custom_apps → ${NEXTCLOUD_CUSTOM_APPS}" + echo " backups → ${NEXTCLOUD_BACKUP_DIR}" +} + +prepare_dirs() { + prepare_host_dirs + restore_config_defaults + ensure_instanceid_config +} + +deploy_stack() { + log_info "Загрузка образов..." + run_compose pull + log_info "Запуск контейнеров..." + run_compose up -d + ensure_app_code_ready + + local waited=0 port + port="$(grep '^NEXTCLOUD_PORT=' .env | cut -d= -f2 || echo 8080)" + log_info "Ожидание Nextcloud на порту ${port}..." + while [[ ${waited} -lt 120 ]]; do + curl -sf "http://127.0.0.1:${port}/status.php" &>/dev/null && break + sleep 5; waited=$((waited + 5)); echo -n "." + done + echo "" + + # shellcheck disable=SC1091 + source .env + ensure_nextcloud_installed "${port}" || true + fix_config_permissions "${NEXTCLOUD_CONFIG_DIR}" +} + +print_summary() { + # shellcheck disable=SC1091 + source .env + log_info "=== Nextcloud (Docker) развёрнут ===" + echo " URL: http://127.0.0.1:${NEXTCLOUD_PORT:-8080}" + echo " Логин: ${NEXTCLOUD_ADMIN_USER:-admin} (пароль в .env)" + echo " config: ${NEXTCLOUD_CONFIG_DIR}" + echo " data: ${NEXTCLOUD_DATADIR}" + log_warn "HTTPS: ./enable-docker-ssl.sh your.domain (порт 8443)" +} + +main() { + parse_args "$@" + log_info "Docker-установка Nextcloud" + ensure_docker_ready + prepare_env + prepare_dirs + deploy_stack + print_summary +} + +main "$@" diff --git a/docker/restore-config.sh b/docker/restore-config.sh new file mode 100644 index 0000000..cfe2e3a --- /dev/null +++ b/docker/restore-config.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Восстановление / сброс config Nextcloud (NEXTCLOUD_CONFIG_DIR из .env) +# ./restore-config.sh — дополнить недостающие шаблоны, починить права +# ./restore-config.sh --force — сброс config и переустановка через occ +# ./restore-config.sh --web — создать CAN_INSTALL для веб-мастера (без occ) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/restore-config.sh" "$@" + +FORCE=false +WEB=false +for arg in "$@"; do + case "${arg}" in + --force|-f) FORCE=true ;; + --web) WEB=true; FORCE=true ;; + -h|--help) + echo "Использование: ./restore-config.sh [--force|--web]" + exit 0 + ;; + esac +done + +[[ -f .env ]] || { echo "[ERROR] Нет .env"; exit 1; } +# shellcheck disable=SC1091 +source .env +[[ -n "${NEXTCLOUD_CONFIG_DIR:-}" ]] || { echo "[ERROR] NEXTCLOUD_CONFIG_DIR не задан в .env"; exit 1; } + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } + +CFG="${NEXTCLOUD_CONFIG_DIR}" +SRC="${SCRIPT_DIR}/config.defaults" + +if [[ ! -d "${SRC}" ]]; then + echo "[ERROR] Нет ${SRC}/" >&2 + exit 1 +fi + +if [[ "${FORCE}" == true ]]; then + warn "Сброс config в ${CFG}/" + if [[ -f "${CFG}/config.php" ]]; then + sudo cp -a "${CFG}/config.php" "${CFG}.config.php.bak.$(date +%s)" 2>/dev/null || true + fi +fi + +info "Восстановление шаблонов config..." +restore_config_templates "${SRC}" "${CFG}" "${NEXTCLOUD_INSTANCEID}" "${FORCE}" +info "Права www-data на ${CFG}/" +fix_config_permissions "${CFG}" + +if docker ps --format '{{.Names}}' | grep -q '^nextcloud-app$'; then + run_compose restart app cron + sleep 5 + + if [[ "${WEB}" == true ]]; then + info "CAN_INSTALL → веб-мастер установки" + sudo touch "${CFG}/CAN_INSTALL" + sudo chown 33:33 "${CFG}/CAN_INSTALL" + sudo chmod 664 "${CFG}/CAN_INSTALL" + warn "Откройте Nextcloud в браузере и завершите установку вручную." + elif [[ "${FORCE}" == true ]] || ! docker exec nextcloud-app test -s /var/www/html/config/config.php 2>/dev/null; then + info "occ maintenance:install..." + occ_maintenance_install + fix_config_permissions "${CFG}" + fi +else + warn "Контейнер nextcloud-app не запущен — выполните ./start.sh" +fi + +info "Готово: ${CFG}/" diff --git a/docker/restore.sh b/docker/restore.sh new file mode 100644 index 0000000..0ceb4d3 --- /dev/null +++ b/docker/restore.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/restore.sh" "$@" +ARCH="${1:-}" +[[ -f "${ARCH}" && -f .env ]] || { echo "Использование: ./restore.sh archive.tar.gz"; exit 1; } +# shellcheck disable=SC1091 +source .env +check_docker_access || exit 1 + +read -r -p "Перезаписать данные? (yes/no): " c +[[ "${c}" == yes ]] || exit 0 + +BACKUP_ROOT="${NEXTCLOUD_BACKUP_DIR:-/var/nextcloud/backups}" +RD="${BACKUP_ROOT}/restore_$$" +ensure_host_dir "${BACKUP_ROOT}" +mkdir -p "${RD}" +tar xzf "${ARCH}" -C "${RD}" --strip-components=1 + +run_compose stop app cron +ensure_host_dir "${NEXTCLOUD_CONFIG_DIR:?}" +sudo find "${NEXTCLOUD_CONFIG_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +sudo cp -a "${RD}/config/." "${NEXTCLOUD_CONFIG_DIR}/" +fix_config_permissions "${NEXTCLOUD_CONFIG_DIR}" +run_docker run --rm -v nextcloud_app_data:/data -v "${RD}:/b:ro" alpine:3.20 \ + sh -c "rm -rf /data/* && tar xzf /b/nextcloud_data.tar.gz -C /data" +run_compose exec -T db mariadb -u root -p"${MYSQL_ROOT_PASSWORD}" "${MYSQL_DATABASE}" < "${RD}/database.sql" +run_compose start app cron +rm -rf "${RD}" +echo "[INFO] Восстановление завершено." diff --git a/docker/setup-env.sh b/docker/setup-env.sh new file mode 100644 index 0000000..ed31be5 --- /dev/null +++ b/docker/setup-env.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Генерация .env с паролями (пути задаются при ./install.sh) +# Запускать БЕЗ sudo: ./setup-env.sh [домен_или_ip] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +drop_root_if_sudo "${SCRIPT_DIR}/setup-env.sh" "$@" + +ENV_FILE="${SCRIPT_DIR}/.env" +ENV_EXAMPLE="${SCRIPT_DIR}/.env.example" + +generate_password() { + local length="${1:-32}" + openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c "${length}" +} + +TRUSTED_DOMAIN="${1:-}" +if [[ -z "${TRUSTED_DOMAIN}" ]]; then + TRUSTED_DOMAIN="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +fi +[[ -z "${TRUSTED_DOMAIN}" ]] && TRUSTED_DOMAIN="localhost" + +if [[ -f "${ENV_FILE}" ]]; then + echo "Файл .env уже существует: ${ENV_FILE}" + exit 1 +fi + +cp "${ENV_EXAMPLE}" "${ENV_FILE}" + +ADMIN_PASS="$(generate_password 24)" +ROOT_PASS="$(generate_password 32)" +DB_PASS="$(generate_password 32)" +REDIS_PASS="$(generate_password 32)" +INSTANCE_ID="$(openssl rand -base64 48 | tr -dc 'a-z0-9' | head -c 12)" +TRUSTED_LIST="localhost 127.0.0.1 ${TRUSTED_DOMAIN}" + +sed -i "s|^NEXTCLOUD_INSTANCEID=.*|NEXTCLOUD_INSTANCEID=${INSTANCE_ID}|" "${ENV_FILE}" +sed -i "s|^NEXTCLOUD_ADMIN_PASSWORD=.*|NEXTCLOUD_ADMIN_PASSWORD=${ADMIN_PASS}|" "${ENV_FILE}" +sed -i "s|^MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=${ROOT_PASS}|" "${ENV_FILE}" +sed -i "s|^MYSQL_PASSWORD=.*|MYSQL_PASSWORD=${DB_PASS}|" "${ENV_FILE}" +sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PASS}|" "${ENV_FILE}" +sed -i "s|^NEXTCLOUD_TRUSTED_DOMAINS=.*|NEXTCLOUD_TRUSTED_DOMAINS=\"${TRUSTED_LIST}\"|" "${ENV_FILE}" + +chmod 600 "${ENV_FILE}" + +echo "Создан ${ENV_FILE}" +echo " Администратор: admin / ${ADMIN_PASS}" +echo " Домен/IP: ${TRUSTED_DOMAIN}" +echo " Пути хранения задаст ./install.sh" diff --git a/docker/start.sh b/docker/start.sh new file mode 100644 index 0000000..10a543f --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/start.sh" "$@" +[[ -f .env ]] || { echo "Сначала: ./install.sh"; exit 1; } +check_docker_access || exit 1 +run_compose up -d +ensure_app_code_ready +# shellcheck disable=SC1091 +source .env +echo "[INFO] Nextcloud: http://127.0.0.1:${NEXTCLOUD_PORT:-8080}" diff --git a/docker/status.sh b/docker/status.sh new file mode 100644 index 0000000..f1e618b --- /dev/null +++ b/docker/status.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/status.sh" "$@" +echo "=== Контейнеры ===" +run_compose ps 2>/dev/null || { echo "Стек не развёрнут"; exit 1; } +if [[ -f .env ]]; then + # shellcheck disable=SC1091 + source .env + PORT="${NEXTCLOUD_PORT:-8080}" + echo "" + curl -sf "http://127.0.0.1:${PORT}/status.php" && echo "" || echo "Nextcloud не отвечает на :${PORT}" +fi diff --git a/docker/stop.sh b/docker/stop.sh new file mode 100644 index 0000000..26e9569 --- /dev/null +++ b/docker/stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/stop.sh" "$@" +run_compose down +echo "[INFO] Стек остановлен. Данные в Docker volumes сохранены." diff --git a/docker/templates/apache-ssl-inside.conf b/docker/templates/apache-ssl-inside.conf new file mode 100644 index 0000000..031c0a9 --- /dev/null +++ b/docker/templates/apache-ssl-inside.conf @@ -0,0 +1,20 @@ +# Apache SSL внутри контейнера Nextcloud +# Сертификаты монтируются с хоста в /etc/ssl/nextcloud/ + + + ServerName localhost + DocumentRoot /var/www/html + + SSLEngine on + SSLCertificateFile /etc/ssl/nextcloud/fullchain.pem + SSLCertificateKeyFile /etc/ssl/nextcloud/privkey.pem + + + Require all granted + AllowOverride All + Options FollowSymLinks MultiViews + + + ErrorLog ${APACHE_LOG_DIR}/nextcloud_ssl_error.log + CustomLog ${APACHE_LOG_DIR}/nextcloud_ssl_access.log combined + diff --git a/docker/uninstall.sh b/docker/uninstall.sh new file mode 100644 index 0000000..0de6155 --- /dev/null +++ b/docker/uninstall.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" +drop_root_if_sudo "${SCRIPT_DIR}/uninstall.sh" "$@" +PURGE=false +[[ "${1:-}" == --purge ]] && PURGE=true + +read -r -p "Удалить контейнеры? (yes/no): " c +[[ "${c}" == yes ]] || exit 0 + +if [[ "${PURGE}" == true ]]; then + run_compose down -v --remove-orphans + if [[ -f .env ]]; then + # shellcheck disable=SC1091 + source .env + if [[ -n "${NEXTCLOUD_CONFIG_DIR:-}" && -d "${NEXTCLOUD_CONFIG_DIR}" ]]; then + backup_and_remove_config_php "${NEXTCLOUD_CONFIG_DIR}" "purge удалил БД volume" + rm -f "${NEXTCLOUD_CONFIG_DIR}/CAN_INSTALL" 2>/dev/null || sudo rm -f "${NEXTCLOUD_CONFIG_DIR}/CAN_INSTALL" 2>/dev/null || true + fix_config_permissions "${NEXTCLOUD_CONFIG_DIR}" || true + fi + fi + echo "[WARN] Данные на хосте НЕ удалены (см. пути в .env):" + echo " NEXTCLOUD_CONFIG_DIR, NEXTCLOUD_DATADIR, APPDATA, CUSTOM_APPS, BACKUP_DIR" +else + run_compose down --remove-orphans +fi +echo "[INFO] Готово." diff --git a/docker/user.txt b/docker/user.txt new file mode 100644 index 0000000..06257fe --- /dev/null +++ b/docker/user.txt @@ -0,0 +1,2 @@ + Администратор: admin / BzM76OP9bz9JfhaHAn1rJ7Gi + Домен/IP: cloud.bratskih.tech diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..c2716d8 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,442 @@ +#!/usr/bin/env bash +# Общие функции для скриптов nextcloud_install +# Подключение: source "$(dirname "$0")/../lib/common.sh" + +# Пользователь, от имени которого запущен sudo (или текущий) +get_invoke_user() { + if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then + echo "${SUDO_USER}" + else + id -un + fi +} + +# Требует root; если нет — перезапускает скрипт через sudo -E +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + exec sudo -E bash "$(readlink -f "${BASH_SOURCE[1]}")" "$@" + fi +} + +# Docker-скрипты не должны работать от root: перезапуск от SUDO_USER +drop_root_if_sudo() { + local script="${1:-${BASH_SOURCE[1]:-}}" + script="$(readlink -f "${script}")" + [[ "${EUID}" -ne 0 ]] && return 0 + + local u="${SUDO_USER:-}" + if [[ -z "${u}" || "${u}" == root ]]; then + echo "[ERROR] Не запускайте docker-скрипты от root." >&2 + echo " Используйте: ./$(basename "${script}")" >&2 + exit 1 + fi + + local home + home="$(getent passwd "${u}" | cut -d: -f6)" + # sudo -u сбрасывает группу docker — перезапуск через sg docker + if command -v sg &>/dev/null && id -nG "${u}" | grep -qw docker; then + exec sudo -u "${u}" -E env HOME="${home}" USER="${u}" LOGNAME="${u}" \ + sg docker -c "bash $(printf '%q' "${script}") $(printf '%q ' "${@:2}")" + fi + exec sudo -u "${u}" -E env HOME="${home}" USER="${u}" LOGNAME="${u}" \ + bash "${script}" "${@:2}" +} + +# docker compose (plugin) или docker-compose (standalone) +compose_bin() { + if docker compose version &>/dev/null 2>&1; then + echo "docker compose" + elif command -v docker-compose &>/dev/null && docker-compose version &>/dev/null 2>&1; then + echo "docker-compose" + else + return 1 + fi +} + +# Создать каталог на хосте (нужен sudo для /var/nextcloud и т.п.) +ensure_host_dir() { + local d="$1" + [[ -z "${d}" ]] && return 1 + if [[ -d "${d}" ]]; then + return 0 + fi + if mkdir -p "${d}" 2>/dev/null; then + return 0 + fi + sudo mkdir -p "${d}" +} + +# Права www-data (uid 33) для каталогов Nextcloud +chown_wwwdata_dir() { + local d="$1" + sudo chown 33:33 "${d}" + sudo chmod 0775 "${d}" +} + +# Каталог config.php: всегда www-data, иначе веб-установщик и occ ломаются +fix_config_permissions() { + local cfg="$1" + [[ -d "${cfg}" ]] || return 1 + sudo chown -R 33:33 "${cfg}" + sudo find "${cfg}" -type d -exec chmod 775 {} \; + sudo find "${cfg}" -type f -exec chmod 664 {} \; + [[ -f "${cfg}/config.php" ]] && sudo chmod 660 "${cfg}/config.php" +} + +# Запись файла в config (через sudo — каталог принадлежит www-data) +config_write_file() { + local f="$1" + ensure_host_dir "$(dirname "$f")" + sudo tee "${f}" >/dev/null + sudo chown 33:33 "${f}" + sudo chmod 664 "${f}" +} + +# Скопировать config.defaults → NEXTCLOUD_CONFIG_DIR +restore_config_templates() { + local src="$1" cfg="$2" instanceid="$3" force="${4:-false}" + + ensure_host_dir "${cfg}" + if [[ "${force}" == true ]]; then + sudo find "${cfg}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + fi + + if [[ -d "${src}" ]]; then + sudo cp -a "${src}/." "${cfg}/" + fi + + config_write_file "${cfg}/instanceid.config.php" < '${instanceid}', +); +EOF + + config_write_file "${cfg}/datadir.config.php" <<'EOF' + false, +); +EOF + + fix_config_permissions "${cfg}" +} + +backup_and_remove_config_php() { + local cfg="$1" reason="${2:-reinstall}" + [[ -f "${cfg}/config.php" ]] || return 0 + + local ts + ts="$(date +%Y%m%d-%H%M%S)" + echo "[WARN] ${reason}: старый config.php сохранён как config.php.bak.${ts}" + sudo cp -a "${cfg}/config.php" "${cfg}/config.php.bak.${ts}" 2>/dev/null || true + sudo rm -f "${cfg}/config.php" "${cfg}/CAN_INSTALL" +} + +config_php_has_instanceid() { + local cfg="$1" instanceid="$2" + [[ -f "${cfg}/config.php" ]] || return 1 + grep -q "'instanceid' => '${instanceid}'" "${cfg}/config.php" 2>/dev/null +} + +docker_volume_exists() { + local volume="$1" + if docker info &>/dev/null 2>&1; then + docker volume inspect "${volume}" &>/dev/null + elif command -v sg &>/dev/null && id -nG | grep -qw docker; then + sg docker -c "docker volume inspect $(printf '%q' "${volume}")" &>/dev/null + else + return 1 + fi +} + +prepare_config_for_reinstall() { + local src="$1" cfg="$2" instanceid="$3" + ensure_host_dir "${cfg}" + + if [[ -f "${cfg}/config.php" && ! -s "${cfg}/config.php" ]]; then + backup_and_remove_config_php "${cfg}" "Пустой config.php" + fi + + if [[ -f "${cfg}/config.php" ]] && ! config_php_has_instanceid "${cfg}" "${instanceid}"; then + backup_and_remove_config_php "${cfg}" "instanceid в .env изменился" + fi + + # Если БД volume отсутствует, это новая установка после purge. Старый config.php + # приведёт к ошибке Nextcloud про CAN_INSTALL, поэтому убираем его заранее. + if [[ -f "${cfg}/config.php" ]] && ! docker_volume_exists nextcloud_db_data; then + backup_and_remove_config_php "${cfg}" "БД volume отсутствует после purge" + fi + + restore_config_templates "${src}" "${cfg}" "${instanceid}" false +} + +# occ maintenance:install (нужен запущенный контейнер app) +occ_maintenance_install() { + # shellcheck disable=SC1091 + source "$(pwd)/.env" + run_compose exec -T -u www-data app php occ maintenance:install \ + --database mysql \ + --database-name "${MYSQL_DATABASE}" \ + --database-user "${MYSQL_USER}" \ + --database-pass "${MYSQL_PASSWORD}" \ + --database-host db \ + --admin-user "${NEXTCLOUD_ADMIN_USER}" \ + --admin-pass "${NEXTCLOUD_ADMIN_PASSWORD}" \ + --data-dir /var/www/html/data +} + +# Установить Nextcloud если status.php: installed=false +ensure_nextcloud_installed() { + local port="${1:-8080}" + local installed + installed="$(curl -sf "http://127.0.0.1:${port}/status.php" 2>/dev/null | grep -o '"installed":[^,]*' | cut -d: -f2 || echo false)" + if [[ "${installed}" == "true" ]]; then + return 0 + fi + # shellcheck disable=SC1091 + source "$(pwd)/.env" + if [[ -f "${NEXTCLOUD_CONFIG_DIR}/config.php" ]]; then + backup_and_remove_config_php "${NEXTCLOUD_CONFIG_DIR}" "Nextcloud не установлен, но config.php уже есть" + restore_config_templates "$(pwd)/config.defaults" "${NEXTCLOUD_CONFIG_DIR}" "${NEXTCLOUD_INSTANCEID}" false + run_compose restart app + sleep 5 + fi + echo "[INFO] Nextcloud не установлен — occ maintenance:install..." + if ! occ_maintenance_install; then + echo "[WARN] occ install не удался. Попробуйте: ./restore-config.sh --force" >&2 + return 1 + fi + fix_config_permissions "${NEXTCLOUD_CONFIG_DIR}" + return 0 +} + +# Вернуть владельца .env пользователю, вызвавшему sudo +fix_env_ownership() { + local env_file="${1:-.env}" + local user + user="$(get_invoke_user)" + if [[ -f "${env_file}" && "${user}" != root && "${EUID}" -eq 0 ]]; then + chown "${user}:${user}" "${env_file}" + fi +} + +# Запуск setup-env от имени обычного пользователя (не root) +run_setup_env() { + local script_dir="$1" + local domain="${2:-}" + local user + user="$(get_invoke_user)" + + if [[ "${EUID}" -eq 0 && "${user}" != root ]]; then + sudo -u "${user}" bash "${script_dir}/setup-env.sh" ${domain:+"${domain}"} + fix_env_ownership "${script_dir}/.env" + else + bash "${script_dir}/setup-env.sh" ${domain:+"${domain}"} + fi +} + +# Проверка: бинарник docker в PATH +docker_is_installed() { + command -v docker &>/dev/null +} + +# Проверка: docker compose v2 доступен +docker_compose_available() { + docker compose version &>/dev/null 2>&1 +} + +# Проверка доступности docker (пробует несколько способов) +check_docker_access() { + if docker info &>/dev/null 2>&1; then + return 0 + fi + # Группа docker ещё не подхватилась в текущей сессии после usermod + if command -v sg &>/dev/null && sg docker -c "docker info" &>/dev/null 2>&1; then + return 0 + fi + if sudo docker info &>/dev/null 2>&1; then + return 0 + fi + if [[ "${EUID}" -eq 0 ]]; then + echo "[ERROR] Docker недоступен. Не запускайте docker-скрипты через sudo." >&2 + echo " Используйте: ./install.sh (без sudo)" >&2 + else + echo "[ERROR] Docker недоступен." >&2 + echo " Добавьте пользователя в группу docker: sudo usermod -aG docker \$USER" >&2 + echo " Или перезапустите install.sh — предложит установку Docker." >&2 + fi + return 1 +} + +# Выполнить docker-команду (docker / sg docker / sudo docker) +run_docker() { + if docker info &>/dev/null 2>&1; then + docker "$@" + elif command -v sg &>/dev/null && id -nG | grep -qw docker; then + sg docker -c "docker $(printf '%q ' "$@")" + elif command -v sg &>/dev/null && sg docker -c "docker $(printf '%q ' "$@")" 2>/dev/null; then + : + else + echo "[ERROR] Docker недоступен" >&2 + return 1 + fi +} + +# Файлы compose (через COMPOSE_FILE — без -f, чтобы не ломать sudo/sg) +compose_env_setup() { + local dir + dir="$(pwd)" + export COMPOSE_FILE="${dir}/docker-compose.yml" + if [[ -f "${dir}/.ssl-enabled" ]]; then + export COMPOSE_FILE="${COMPOSE_FILE}:${dir}/docker-compose.ssl.yml" + fi +} + +run_compose() { + if [[ "${EUID}" -eq 0 ]]; then + echo "[ERROR] Docker Compose нельзя запускать от root." >&2 + echo " Запустите: ./install.sh (без sudo)" >&2 + return 1 + fi + + compose_env_setup + local dir cb + dir="$(pwd)" + cb="$(compose_bin)" || { + echo "[ERROR] Нужен Docker Compose v2 (docker compose) или docker-compose." >&2 + echo " sudo apt install docker-compose-plugin" >&2 + return 1 + } + + _compose_run() { + # shellcheck disable=SC2086 + (cd "${dir}" && ${cb} "$@") + } + + if docker info &>/dev/null 2>&1; then + _compose_run "$@" + return $? + fi + + if command -v sg &>/dev/null && id -nG | grep -qw docker; then + sg docker -c "cd $(printf '%q' "${dir}") && COMPOSE_FILE=$(printf '%q' "${COMPOSE_FILE}") ${cb} $(printf '%q ' "$@")" + return $? + fi + + echo "[ERROR] Docker недоступен." >&2 + echo " sudo usermod -aG docker $(id -un) && newgrp docker" >&2 + return 1 +} + +app_code_ready() { + run_compose exec -T app test -f /var/www/html/lib/versioncheck.php &>/dev/null +} + +wait_for_app_code() { + local waited=0 limit="${1:-90}" + while [[ "${waited}" -lt "${limit}" ]]; do + app_code_ready && return 0 + sleep 3 + waited=$((waited + 3)) + done + return 1 +} + +repair_app_code_volume() { + echo "[WARN] Код Nextcloud в volume nextcloud_app_data неполный." + echo "[WARN] Пересоздаю только nextcloud_app_data (config/data/БД не трогаются)." + run_compose down --remove-orphans + run_docker volume rm nextcloud_app_data >/dev/null 2>&1 || true + run_compose up -d + wait_for_app_code 120 +} + +ensure_app_code_ready() { + if wait_for_app_code 90; then + return 0 + fi + repair_app_code_volume || { + echo "[ERROR] Не удалось подготовить код Nextcloud в /var/www/html." >&2 + echo " Проверьте: docker logs nextcloud-app" >&2 + return 1 + } +} + +# Предложить/выполнить установку Docker (вызывается из install.sh) +# $1 = путь к install-docker.sh, $2 = auto (yes/no/skip/prompt) +ensure_docker() { + local installer="$1" + local auto="${2:-prompt}" + + # Docker уже работает (напрямую, через sg или sudo) + if docker info &>/dev/null 2>&1 && docker compose version &>/dev/null 2>&1; then + return 0 + fi + if command -v sg &>/dev/null && sg docker -c "docker info" &>/dev/null 2>&1 \ + && sg docker -c "docker compose version" &>/dev/null 2>&1; then + return 0 + fi + if sudo docker info &>/dev/null 2>&1 && sudo docker compose version &>/dev/null 2>&1; then + return 0 + fi + + if [[ "${auto}" == "skip" ]]; then + check_docker_access || return 1 + return 0 + fi + + # Docker есть, но нет compose plugin + if docker_is_installed && ! docker_compose_available; then + echo "[ERROR] Docker установлен, но нет Compose v2." >&2 + echo " sudo apt install docker-compose-plugin" >&2 + return 1 + fi + + echo "" + if docker_is_installed; then + echo "Docker установлен, но недоступен без sudo (возможно, нужен перелогин)." + echo "Скрипт попробует добавить вас в группу docker." + else + echo "Docker не найден." + echo "Скрипт может установить Docker Engine + Compose v2 (Ubuntu/Debian)." + fi + echo "Потребуется sudo и доступ к интернету." + echo "" + + local answer="${auto}" + if [[ "${answer}" == "prompt" ]]; then + read -r -p "Продолжить установку/настройку Docker? (yes/no): " answer + fi + + if [[ "${answer}" != "yes" ]]; then + echo "[INFO] Пропущено. Вручную: sudo ${installer} $(id -un)" + return 1 + fi + + [[ -f "${installer}" ]] || { echo "[ERROR] Не найден ${installer}" >&2; return 1; } + + echo "[INFO] Запуск (sudo)..." + sudo bash "${installer}" "$(id -un)" + sleep 2 + + if docker info &>/dev/null 2>&1 || sg docker -c "docker info" &>/dev/null 2>&1 \ + || sudo docker info &>/dev/null 2>&1; then + echo "[INFO] Docker готов." + return 0 + fi + + echo "[WARN] Docker установлен. Перелогиньтесь или: newgrp docker" + return 1 +} + +# occ от www-data (работает и под sudo, и под root) +run_occ() { + local nc_dir="$1" + shift + if [[ "${EUID}" -eq 0 ]]; then + sudo -u www-data php "${nc_dir}/occ" "$@" + else + sudo -u www-data php "${nc_dir}/occ" "$@" + fi +} diff --git a/lib/node-server.js b/lib/node-server.js new file mode 100644 index 0000000..63c6883 --- /dev/null +++ b/lib/node-server.js @@ -0,0 +1,189 @@ +/** + * Минимальный HTTP-сервер без Express: node:http / node:http2 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import http2 from 'node:http2'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', + '.png': 'image/png', +}; + +export function loadEnv(envPath) { + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const i = t.indexOf('='); + if (i < 0) continue; + const key = t.slice(0, i).trim(); + let val = t.slice(i + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = val; + } +} + +export function sendJson(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +export function readJsonBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + if (!chunks.length) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +export function parseCookies(req) { + const raw = req.headers.cookie || ''; + const out = {}; + for (const part of raw.split(';')) { + const [k, ...v] = part.trim().split('='); + if (k) out[k] = decodeURIComponent(v.join('=')); + } + return out; +} + +export function setCookie(res, name, value, opts = {}) { + const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']; + if (opts.maxAge) parts.push(`Max-Age=${opts.maxAge}`); + if (opts.clear) parts.push('Max-Age=0'); + const prev = res.getHeader('Set-Cookie'); + const next = Array.isArray(prev) ? [...prev, parts.join('; ')] : prev ? [prev, parts.join('; ')] : [parts.join('; ')]; + res.setHeader('Set-Cookie', next); +} + +export function signSession(data, secret) { + const payload = Buffer.from(JSON.stringify(data)).toString('base64url'); + const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); + return `${payload}.${sig}`; +} + +export function verifySession(token, secret) { + if (!token) return null; + const dot = token.lastIndexOf('.'); + if (dot < 0) return null; + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); + if (sig !== expected) return null; + try { + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + return null; + } +} + +export function clientIp(req) { + return (req.socket?.remoteAddress || '').replace('::ffff:', ''); +} + +export function matchRoute(method, pathname, routes) { + for (const r of routes) { + if (r.method !== method) continue; + const pattern = r.path.replace(/:([^/]+)/g, '([^/]+)'); + const re = new RegExp(`^${pattern}$`); + const m = pathname.match(re); + if (!m) continue; + const keys = [...r.path.matchAll(/:([^/]+)/g)].map((x) => x[1]); + const params = {}; + keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); }); + return { handler: r.handler, params }; + } + return null; +} + +export async function serveStatic(publicDir, pathname) { + const base = path.resolve(publicDir); + const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, ''); + const filePath = path.resolve(base, rel); + if (!filePath.startsWith(base + path.sep) && filePath !== base) return null; + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return null; + const ext = path.extname(filePath); + return { + body: fs.readFileSync(filePath), + type: MIME[ext] || 'application/octet-stream', + }; + } catch { + return null; + } +} + +/** Rate limit: max запросов за windowMs на IP */ +export function createRateLimiter({ windowMs, max }) { + const hits = new Map(); + return (ip) => { + const now = Date.now(); + let entry = hits.get(ip); + if (!entry || now - entry.start > windowMs) { + entry = { start: now, count: 0 }; + hits.set(ip, entry); + } + entry.count += 1; + return entry.count <= max; + }; +} + +/** + * @param {function} onRequest async (req, res, ctx) => void + * @param {{ http2?: boolean, tls?: { key, cert }, host, port }} opts + */ +export function listen(onRequest, opts) { + const handler = async (req, res) => { + try { + await onRequest(req, res); + } catch (e) { + if (!res.headersSent) sendJson(res, 500, { error: e.message || 'Internal error' }); + } + }; + + let server; + if (opts.tls?.key && opts.tls?.cert) { + server = http2.createSecureServer( + { key: fs.readFileSync(opts.tls.key), cert: fs.readFileSync(opts.tls.cert) }, + handler + ); + } else if (opts.http2) { + server = http2.createServer(handler); + } else { + server = http.createServer(handler); + } + + server.listen(opts.port, opts.host, () => { + const proto = opts.tls ? 'https' : 'http'; + const h2 = opts.http2 || opts.tls ? ' HTTP/2' : ''; + console.log(`Listening → ${proto}://${opts.host}:${opts.port}${h2}`); + }); + return server; +} + +export function dirFromMeta(metaUrl) { + return path.dirname(fileURLToPath(metaUrl)); +} diff --git a/native/.env.example b/native/.env.example new file mode 100644 index 0000000..5506205 --- /dev/null +++ b/native/.env.example @@ -0,0 +1,33 @@ +# Нативная установка Nextcloud в систему (LAMP + Redis) +# cp .env.example .env или ./setup-env.sh + +# Версия Nextcloud: latest или конкретная (например 30.0.14) +NEXTCLOUD_VERSION=latest + +# Каталог установки Nextcloud на сервере +NEXTCLOUD_INSTALL_DIR=/var/www/nextcloud + +# Режим Apache: vhost (отдельный домен) или subdir (https://host/nextcloud) +APACHE_MODE=vhost + +# Имя виртуального хоста (ServerName) или базовый URL +NEXTCLOUD_DOMAIN=localhost + +# Порт Apache (80 по умолчанию) +APACHE_PORT=80 + +# Администратор Nextcloud +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=CHANGE_ME_admin_password + +# MariaDB +DB_NAME=nextcloud +DB_USER=nextcloud +DB_PASSWORD=CHANGE_ME_db_password + +# Redis (локальный сервис redis-server) +REDIS_PASSWORD=CHANGE_ME_redis_password + +# PHP (файл /etc/php/*/apache2/conf.d/99-nextcloud.ini) +PHP_MEMORY_LIMIT=512M +PHP_UPLOAD_LIMIT=10G diff --git a/native/backup.sh b/native/backup.sh new file mode 100644 index 0000000..cb520cf --- /dev/null +++ b/native/backup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Резервная копия нативной установки +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +[[ "${EUID}" -ne 0 ]] && exec sudo -E bash "${SCRIPT_DIR}/backup.sh" "$@" + +cd "${SCRIPT_DIR}" +[[ -f .env ]] || { echo "[ERROR] .env не найден"; exit 1; } +# shellcheck disable=SC1091 +source .env + +TS="$(date +%Y-%m-%d_%H-%M-%S)" +BD="${SCRIPT_DIR}/backups/backup_${TS}" +ARCH="${SCRIPT_DIR}/backups/nextcloud_native_${TS}.tar.gz" +mkdir -p "${BD}" + +run_occ "${NEXTCLOUD_INSTALL_DIR}" maintenance:mode --on 2>/dev/null || true + +mariadb-dump -u "${DB_USER}" -p"${DB_PASSWORD}" \ + --single-transaction "${DB_NAME}" > "${BD}/database.sql" + +tar czf "${BD}/nextcloud_files.tar.gz" \ + -C "$(dirname "${NEXTCLOUD_INSTALL_DIR}")" "$(basename "${NEXTCLOUD_INSTALL_DIR}")" + +cp .env "${BD}/install.env" + +run_occ "${NEXTCLOUD_INSTALL_DIR}" maintenance:mode --off 2>/dev/null || true + +tar czf "${ARCH}" -C "${SCRIPT_DIR}/backups" "backup_${TS}" +rm -rf "${BD}" +echo "[INFO] Бэкап: ${ARCH}" diff --git a/native/install.sh b/native/install.sh new file mode 100644 index 0000000..d0ab317 --- /dev/null +++ b/native/install.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Нативная установка Nextcloud (Ubuntu/Debian): Apache + MariaDB + PHP + Redis +# Работает как: ./install.sh или sudo ./install.sh (автоповышение прав) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +# Автоповышение до root, если скрипт запущен без sudo +if [[ "${EUID}" -ne 0 ]]; then + exec sudo -E bash "${SCRIPT_DIR}/install.sh" "$@" +fi + +log_info() { echo "[INFO] $*"; } +log_warn() { echo "[WARN] $*"; } +log_error() { echo "[ERROR] $*" >&2; } + +prepare_env() { + local domain="${1:-}" + cd "${SCRIPT_DIR}" + if [[ ! -f ".env" ]]; then + run_setup_env "${SCRIPT_DIR}" "${domain}" + fi + grep -q CHANGE_ME .env && { log_error "В .env остались CHANGE_ME — удалите .env и перезапустите"; exit 1; } + # shellcheck disable=SC1091 + source .env + fix_env_ownership "${SCRIPT_DIR}/.env" +} + +install_packages() { + log_info "Установка пакетов..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq apache2 mariadb-server redis-server libapache2-mod-php \ + php-gd php-mysql php-curl php-mbstring php-intl php-gmp php-xml php-imagick \ + php-zip php-bcmath php-apcu php-redis php-fileinfo php-opcache \ + wget curl bzip2 ca-certificates openssl + systemctl enable --now mariadb redis-server apache2 +} + +setup_database() { + log_info "Создание БД MariaDB..." + mariadb <> /etc/redis/redis.conf + fi + systemctl restart redis-server +} + +download_nextcloud() { + local version="${NEXTCLOUD_VERSION:-latest}" + local base_url="https://download.nextcloud.com/server/releases" + local archive tmp_dir + tmp_dir="$(mktemp -d)" + archive="nextcloud-${version}.tar.bz2" + [[ "${version}" == "latest" ]] && archive="latest.tar.bz2" + + log_info "Загрузка Nextcloud (${version})..." + wget -q "${base_url}/${archive}" -O "${tmp_dir}/${archive}" + wget -q "${base_url}/${archive}.sha256" -O "${tmp_dir}/${archive}.sha256" + (cd "${tmp_dir}" && sha256sum -c "${archive}.sha256") + + rm -rf "${NEXTCLOUD_INSTALL_DIR}" + tar -xjf "${tmp_dir}/${archive}" -C /var/www + rm -rf "${tmp_dir}" +} + +configure_apache() { + log_info "Настройка Apache (${APACHE_MODE})..." + a2enmod rewrite headers env dir mime 2>/dev/null || true + a2dissite 000-default.conf 2>/dev/null || true + + local tpl="${SCRIPT_DIR}/templates/apache-vhost.conf.template" + [[ "${APACHE_MODE}" == "subdir" ]] && tpl="${SCRIPT_DIR}/templates/apache-subdir.conf.template" + + sed -e "s|__NEXTCLOUD_INSTALL_DIR__|${NEXTCLOUD_INSTALL_DIR}|g" \ + -e "s|__NEXTCLOUD_DOMAIN__|${NEXTCLOUD_DOMAIN}|g" \ + -e "s|__APACHE_PORT__|${APACHE_PORT:-80}|g" \ + "${tpl}" > /etc/apache2/sites-available/nextcloud.conf + + a2ensite nextcloud.conf + apache2ctl configtest + systemctl reload apache2 +} + +configure_php() { + log_info "Настройка PHP..." + local php_ver php_ini + php_ver="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')" + php_ini="/etc/php/${php_ver}/apache2/conf.d/99-nextcloud.ini" + sed -e "s|__PHP_MEMORY_LIMIT__|${PHP_MEMORY_LIMIT:-512M}|g" \ + -e "s|__PHP_UPLOAD_LIMIT__|${PHP_UPLOAD_LIMIT:-10G}|g" \ + "${SCRIPT_DIR}/templates/php-nextcloud.ini.template" > "${php_ini}" + systemctl reload apache2 +} + +run_occ_install() { + log_info "Инициализация Nextcloud (occ)..." + chown -R www-data:www-data "${NEXTCLOUD_INSTALL_DIR}" + mkdir -p "${NEXTCLOUD_INSTALL_DIR}/data" + chmod 750 "${NEXTCLOUD_INSTALL_DIR}/data" + + run_occ "${NEXTCLOUD_INSTALL_DIR}" maintenance:install \ + --database mysql --database-name "${DB_NAME}" \ + --database-user "${DB_USER}" --database-pass "${DB_PASSWORD}" \ + --admin-user "${NEXTCLOUD_ADMIN_USER}" --admin-pass "${NEXTCLOUD_ADMIN_PASSWORD}" \ + --data-dir "${NEXTCLOUD_INSTALL_DIR}/data" + + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set trusted_domains 0 --value=localhost + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set trusted_domains 1 --value=127.0.0.1 + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set trusted_domains 2 --value="${NEXTCLOUD_DOMAIN}" + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set memcache.local --value='\\OC\\Memcache\\APCu' + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set memcache.distributed --value='\\OC\\Memcache\\Redis' + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set memcache.locking --value='\\OC\\Memcache\\Redis' + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set redis host --value=127.0.0.1 + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set redis port --value=6379 + run_occ "${NEXTCLOUD_INSTALL_DIR}" config:system:set redis password --value="${REDIS_PASSWORD}" + run_occ "${NEXTCLOUD_INSTALL_DIR}" background:cron +} + +setup_cron() { + sed "s|__NEXTCLOUD_INSTALL_DIR__|${NEXTCLOUD_INSTALL_DIR}|g" \ + "${SCRIPT_DIR}/templates/cron-nextcloud.template" > /etc/cron.d/nextcloud + chmod 644 /etc/cron.d/nextcloud +} + +print_summary() { + local url="http://${NEXTCLOUD_DOMAIN}" + [[ "${APACHE_MODE}" == "subdir" ]] && url="http://${NEXTCLOUD_DOMAIN}/nextcloud" + echo "" + log_info "=== Nextcloud (нативная установка) готов ===" + echo " URL: ${url}" + echo " Каталог: ${NEXTCLOUD_INSTALL_DIR}" + echo " Администратор: ${NEXTCLOUD_ADMIN_USER} (пароль в native/.env)" + log_warn "HTTPS: sudo certbot --apache -d ${NEXTCLOUD_DOMAIN}" +} + +main() { + prepare_env "${1:-}" + install_packages + setup_database + configure_redis + download_nextcloud + configure_apache + configure_php + run_occ_install + setup_cron + mkdir -p "${SCRIPT_DIR}/backups" + cp .env "${SCRIPT_DIR}/backups/.env.installed.$(date +%Y%m%d)" + fix_env_ownership "${SCRIPT_DIR}/.env" + print_summary +} + +main "$@" diff --git a/native/restore.sh b/native/restore.sh new file mode 100644 index 0000000..af2074c --- /dev/null +++ b/native/restore.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Восстановление из архива backup.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +[[ "${EUID}" -ne 0 ]] && exec sudo -E bash "${SCRIPT_DIR}/restore.sh" "$@" + +ARCH="${1:-}" +cd "${SCRIPT_DIR}" +[[ -f .env && -f "${ARCH}" ]] || { echo "Использование: ./restore.sh archive.tar.gz"; exit 1; } +# shellcheck disable=SC1091 +source .env + +read -r -p "Перезаписать ${NEXTCLOUD_INSTALL_DIR} и БД? (yes/no): " c +[[ "${c}" == "yes" ]] || exit 0 + +RD="${SCRIPT_DIR}/backups/restore_$$" +mkdir -p "${RD}" +tar xzf "${ARCH}" -C "${RD}" --strip-components=1 + +systemctl stop apache2 +run_occ "${NEXTCLOUD_INSTALL_DIR}" maintenance:mode --on 2>/dev/null || true + +rm -rf "${NEXTCLOUD_INSTALL_DIR}" +tar xzf "${RD}/nextcloud_files.tar.gz" -C /var/www + +mariadb -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" +mariadb "${DB_NAME}" < "${RD}/database.sql" + +chown -R www-data:www-data "${NEXTCLOUD_INSTALL_DIR}" +run_occ "${NEXTCLOUD_INSTALL_DIR}" maintenance:mode --off 2>/dev/null || true + +systemctl start apache2 +rm -rf "${RD}" +echo "[INFO] Восстановление завершено" diff --git a/native/setup-env.sh b/native/setup-env.sh new file mode 100644 index 0000000..efbbcdf --- /dev/null +++ b/native/setup-env.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Генерация .env для нативной установки +# Можно запускать без sudo: ./setup-env.sh [домен] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +ENV_FILE="${SCRIPT_DIR}/.env" +ENV_EXAMPLE="${SCRIPT_DIR}/.env.example" + +generate_password() { + openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}" +} + +DOMAIN="${1:-}" +if [[ -z "${DOMAIN}" ]]; then + DOMAIN="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +fi +[[ -z "${DOMAIN}" ]] && DOMAIN="localhost" + +[[ -f "${ENV_FILE}" ]] && { echo ".env уже существует"; exit 1; } +cp "${ENV_EXAMPLE}" "${ENV_FILE}" + +sed -i "s|^NEXTCLOUD_ADMIN_PASSWORD=.*|NEXTCLOUD_ADMIN_PASSWORD=$(generate_password 24)|" "${ENV_FILE}" +sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(generate_password 32)|" "${ENV_FILE}" +sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(generate_password 32)|" "${ENV_FILE}" +sed -i "s|^NEXTCLOUD_DOMAIN=.*|NEXTCLOUD_DOMAIN=${DOMAIN}|" "${ENV_FILE}" + +chmod 600 "${ENV_FILE}" +fix_env_ownership "${ENV_FILE}" + +# shellcheck disable=SC1091 +source "${ENV_FILE}" +echo "Создан ${ENV_FILE}" +echo " Домен: ${NEXTCLOUD_DOMAIN}" +echo " Администратор: ${NEXTCLOUD_ADMIN_USER} / ${NEXTCLOUD_ADMIN_PASSWORD}" diff --git a/native/start.sh b/native/start.sh new file mode 100644 index 0000000..020bbfa --- /dev/null +++ b/native/start.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Запуск сервисов нативной установки (автоповышение через sudo) +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[[ "${EUID}" -ne 0 ]] && exec sudo -E bash "${SCRIPT_DIR}/start.sh" "$@" +systemctl start mariadb redis-server apache2 +echo "[INFO] Сервисы запущены: apache2, mariadb, redis-server" diff --git a/native/status.sh b/native/status.sh new file mode 100644 index 0000000..0398884 --- /dev/null +++ b/native/status.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Статус нативной установки (systemctl + occ) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +# systemctl и occ требуют root — автоповышение +if [[ "${EUID}" -ne 0 ]]; then + exec sudo -E bash "${SCRIPT_DIR}/status.sh" "$@" +fi + +echo "=== Системные сервисы ===" +for svc in apache2 mariadb redis-server; do + if systemctl is-active --quiet "${svc}"; then + echo " ${svc}: active" + else + echo " ${svc}: inactive" + fi +done + +cd "${SCRIPT_DIR}" +if [[ -f .env ]]; then + # shellcheck disable=SC1091 + source .env + echo "" + echo "=== Nextcloud ===" + if [[ -d "${NEXTCLOUD_INSTALL_DIR}" ]]; then + run_occ "${NEXTCLOUD_INSTALL_DIR}" status 2>/dev/null || echo " occ status недоступен (ещё не установлен?)" + else + echo " Каталог ${NEXTCLOUD_INSTALL_DIR} не найден" + fi +fi diff --git a/native/stop.sh b/native/stop.sh new file mode 100644 index 0000000..ec20efa --- /dev/null +++ b/native/stop.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Остановка Apache (MariaDB/Redis продолжают работать) +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[[ "${EUID}" -ne 0 ]] && exec sudo -E bash "${SCRIPT_DIR}/stop.sh" "$@" +systemctl stop apache2 +echo "[INFO] Apache остановлен" diff --git a/native/templates/apache-subdir.conf.template b/native/templates/apache-subdir.conf.template new file mode 100644 index 0000000..c198ace --- /dev/null +++ b/native/templates/apache-subdir.conf.template @@ -0,0 +1,14 @@ +# Шаблон для установки Nextcloud в подкаталог (/nextcloud) +# Плейсхолдеры заменяются скриптом install.sh + +Alias /nextcloud "__NEXTCLOUD_INSTALL_DIR__/" + + + Require all granted + AllowOverride All + Options FollowSymLinks MultiViews + + + Dav off + + diff --git a/native/templates/apache-vhost.conf.template b/native/templates/apache-vhost.conf.template new file mode 100644 index 0000000..a81b98b --- /dev/null +++ b/native/templates/apache-vhost.conf.template @@ -0,0 +1,20 @@ +# Шаблон виртуального хоста Apache для Nextcloud +# Плейсхолдеры заменяются скриптом install.sh + + + ServerName __NEXTCLOUD_DOMAIN__ + DocumentRoot __NEXTCLOUD_INSTALL_DIR__ + + + Require all granted + AllowOverride All + Options FollowSymLinks MultiViews + + + Dav off + + + + ErrorLog ${APACHE_LOG_DIR}/nextcloud_error.log + CustomLog ${APACHE_LOG_DIR}/nextcloud_access.log combined + diff --git a/native/templates/cron-nextcloud.template b/native/templates/cron-nextcloud.template new file mode 100644 index 0000000..c7d6cec --- /dev/null +++ b/native/templates/cron-nextcloud.template @@ -0,0 +1,6 @@ +# Cron-задачи Nextcloud (создаётся install.sh) +SHELL=/bin/sh +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Фоновые задачи каждые 5 минут +*/5 * * * * www-data php -f __NEXTCLOUD_INSTALL_DIR__/cron.php diff --git a/native/templates/php-nextcloud.ini.template b/native/templates/php-nextcloud.ini.template new file mode 100644 index 0000000..709ff1d --- /dev/null +++ b/native/templates/php-nextcloud.ini.template @@ -0,0 +1,13 @@ +; PHP-настройки для Nextcloud (создаётся install.sh) +memory_limit = __PHP_MEMORY_LIMIT__ +upload_max_filesize = __PHP_UPLOAD_LIMIT__ +post_max_size = __PHP_UPLOAD_LIMIT__ +max_execution_time = 360 +output_buffering = 0 +opcache.enable = 1 +opcache.enable_cli = 1 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 10000 +opcache.memory_consumption = 128 +opcache.save_comments = 1 +opcache.revalidate_freq = 1 diff --git a/native/uninstall.sh b/native/uninstall.sh new file mode 100644 index 0000000..1242b7e --- /dev/null +++ b/native/uninstall.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Удаление нативной установки +# ./uninstall.sh — удалить Nextcloud и конфиги Apache +# ./uninstall.sh --purge — также удалить БД +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[[ "${EUID}" -ne 0 ]] && exec sudo -E bash "${SCRIPT_DIR}/uninstall.sh" "$@" + +PURGE=false +[[ "${1:-}" == "--purge" ]] && PURGE=true + +cd "${SCRIPT_DIR}" +[[ -f .env ]] && source .env + +read -r -p "Удалить Nextcloud? (yes/no): " c +[[ "${c}" == "yes" ]] || exit 0 + +systemctl stop apache2 2>/dev/null || true +a2dissite nextcloud.conf 2>/dev/null || true +rm -f /etc/apache2/sites-available/nextcloud.conf /etc/cron.d/nextcloud + +if [[ -n "${NEXTCLOUD_INSTALL_DIR:-}" && -d "${NEXTCLOUD_INSTALL_DIR}" ]]; then + rm -rf "${NEXTCLOUD_INSTALL_DIR}" +fi + +if [[ "${PURGE}" == true && -n "${DB_NAME:-}" ]]; then + mariadb -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`;" + mariadb -e "DROP USER IF EXISTS '${DB_USER:-nextcloud}'@'localhost';" +fi + +systemctl reload apache2 2>/dev/null || true +echo "[INFO] Nextcloud удалён" diff --git a/register-ui/.env.example b/register-ui/.env.example new file mode 100644 index 0000000..53884c6 --- /dev/null +++ b/register-ui/.env.example @@ -0,0 +1,31 @@ +# URL Nextcloud (HTTP или HTTPS) +NEXTCLOUD_URL=https://127.0.0.1:8443 + +# Порт страницы регистрации +REGISTER_UI_PORT=3001 + +# Учётная запись с правами создавать пользователей (обычно admin) +REGISTRATION_ADMIN_USER=admin +REGISTRATION_ADMIN_PASSWORD=your-admin-password + +# Квота по умолчанию для новых пользователей +DEFAULT_QUOTA=5GB + +# Группа по умолчанию +DEFAULT_GROUP=users + +# Включить регистрацию (false = страница покажет «закрыто») +REGISTRATION_ENABLED=true + +# Опционально: разрешить только эти email-домены (через запятую) +# ALLOWED_EMAIL_DOMAINS=company.com,example.org + +# Лимит: не более N регистраций в час с одного IP +RATE_LIMIT_PER_HOUR=10 + +# node:http (default) или node:http2 +# HTTP2=true + +# HTTPS (опционально) +# SSL_KEY=./certs/privkey.pem +# SSL_CERT=./certs/fullchain.pem diff --git a/register-ui/.gitignore b/register-ui/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/register-ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/register-ui/package.json b/register-ui/package.json new file mode 100644 index 0000000..22bfe5f --- /dev/null +++ b/register-ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "nextcloud-register-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Self-registration page (node:http / node:http2)", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/register-ui/public/app.js b/register-ui/public/app.js new file mode 100644 index 0000000..1eb5e97 --- /dev/null +++ b/register-ui/public/app.js @@ -0,0 +1,71 @@ +const form = document.getElementById('register-form'); +const formError = document.getElementById('form-error'); +const successBlock = document.getElementById('success'); +const closedBanner = document.getElementById('closed-banner'); +const submitBtn = document.getElementById('submit-btn'); + +let config = {}; + +async function loadConfig() { + const res = await fetch('/api/config'); + config = await res.json(); + + document.getElementById('footer-login').href = config.nextcloudUrl; + document.getElementById('login-link').href = config.nextcloudUrl; + + if (config.allowedEmailDomains?.length) { + document.getElementById('email-hint').textContent = + `Только: ${config.allowedEmailDomains.join(', ')}`; + } + + if (!config.enabled) { + closedBanner.classList.remove('hidden'); + form.classList.add('hidden'); + submitBtn.disabled = true; + } +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + formError.classList.add('hidden'); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + const password2 = document.getElementById('password2').value; + const email = document.getElementById('email').value.trim(); + const displayName = document.getElementById('displayName').value.trim(); + + if (password !== password2) { + formError.textContent = 'Пароли не совпадают'; + formError.classList.remove('hidden'); + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = 'Создание…'; + + try { + const res = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, email, displayName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Ошибка'); + + form.classList.add('hidden'); + successBlock.classList.remove('hidden'); + document.getElementById('success-user').textContent = username; + document.getElementById('login-link').href = data.loginUrl || config.nextcloudUrl; + } catch (err) { + formError.textContent = err.message; + formError.classList.remove('hidden'); + submitBtn.disabled = false; + submitBtn.textContent = 'Зарегистрироваться'; + } +}); + +loadConfig().catch(() => { + formError.textContent = 'Не удалось загрузить настройки'; + formError.classList.remove('hidden'); +}); diff --git a/register-ui/public/index.html b/register-ui/public/index.html new file mode 100644 index 0000000..708b350 --- /dev/null +++ b/register-ui/public/index.html @@ -0,0 +1,72 @@ + + + + + + Регистрация — Nextcloud + + + +
+
+

Создать аккаунт

+

Облачное хранилище для файлов, календаря и синхронизации

+
+ + + +
+ + + + + + + + + + + + + +
+ + + + +
+ + + diff --git a/register-ui/public/styles.css b/register-ui/public/styles.css new file mode 100644 index 0000000..d300ab9 --- /dev/null +++ b/register-ui/public/styles.css @@ -0,0 +1,110 @@ +:root { + --bg: #0b1628; + --card: #152238; + --accent: #0082c9; + --accent-hover: #006aa3; + --text: #eef3fa; + --muted: #8fa3bf; + --error: #ff8a80; + --ok: #6fcf97; + --warn: #f2c94c; + --border: #2a4060; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: radial-gradient(ellipse at top, #1a3050 0%, var(--bg) 55%); + color: var(--text); +} + +.card { + width: 100%; + max-width: 440px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); +} + +h1 { margin: 0 0 8px; font-size: 1.75rem; } +h2 { margin: 0 0 12px; } +.muted { color: var(--muted); font-size: 0.92rem; } +.hint { display: block; font-size: 0.8rem; color: var(--muted); margin-top: 4px; } +.req { color: var(--accent); } +.hidden { display: none !important; } +.error { color: var(--error); font-size: 0.9rem; } + +label { + display: block; + margin-bottom: 16px; + font-size: 0.88rem; + font-weight: 500; +} + +input { + display: block; + width: 100%; + margin-top: 6px; + padding: 11px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 1rem; +} + +input:focus { + outline: 2px solid var(--accent); + border-color: transparent; +} + +button, .button { + display: block; + width: 100%; + padding: 13px; + border: none; + border-radius: 10px; + background: var(--accent); + color: #fff; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-align: center; + text-decoration: none; +} + +button:hover, .button:hover { background: var(--accent-hover); } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.banner { + padding: 12px 14px; + border-radius: 10px; + margin-bottom: 20px; + font-size: 0.9rem; +} +.banner.warn { + background: rgba(242, 201, 76, 0.12); + border: 1px solid var(--warn); + color: var(--warn); +} + +.success { text-align: center; padding: 12px 0; } +.success .button { margin-top: 16px; } + +.footer { + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--border); + text-align: center; +} + +.footer a { color: var(--accent); } diff --git a/register-ui/server.js b/register-ui/server.js new file mode 100644 index 0000000..6627775 --- /dev/null +++ b/register-ui/server.js @@ -0,0 +1,180 @@ +import path from 'node:path'; +import { + loadEnv, + sendJson, + readJsonBody, + clientIp, + matchRoute, + serveStatic, + listen, + createRateLimiter, + dirFromMeta, +} from '../lib/node-server.js'; + +const __dirname = dirFromMeta(import.meta.url); +loadEnv(path.join(__dirname, '.env')); + +const PORT = Number(process.env.REGISTER_UI_PORT || 3001); +const HOST = process.env.REGISTER_UI_HOST || '0.0.0.0'; +const USE_HTTP2 = process.env.HTTP2 === 'true'; +const NEXTCLOUD_URL = (process.env.NEXTCLOUD_URL || 'http://127.0.0.1:8080').replace(/\/$/, ''); +const ADMIN_USER = process.env.REGISTRATION_ADMIN_USER || ''; +const ADMIN_PASS = process.env.REGISTRATION_ADMIN_PASSWORD || ''; +const DEFAULT_QUOTA = process.env.DEFAULT_QUOTA || '5GB'; +const DEFAULT_GROUP = process.env.DEFAULT_GROUP || 'users'; +const ENABLED = process.env.REGISTRATION_ENABLED !== 'false'; +const PUBLIC_DIR = path.join(__dirname, 'public'); +const ALLOWED_DOMAINS = (process.env.ALLOWED_EMAIL_DOMAINS || '') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + +const TLS = + process.env.SSL_KEY && process.env.SSL_CERT + ? { key: process.env.SSL_KEY, cert: process.env.SSL_CERT } + : null; + +const rateLimit = createRateLimiter({ + windowMs: 3600_000, + max: Number(process.env.RATE_LIMIT_PER_HOUR || 10), +}); + +function authHeader() { + return 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64'); +} + +async function ocs(method, ocsPath, body) { + const headers = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + Authorization: authHeader(), + }; + const init = { method, headers }; + if (body) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + init.body = new URLSearchParams(body).toString(); + } + const res = await fetch(`${NEXTCLOUD_URL}${ocsPath}`, init); + const data = await res.json().catch(() => ({})); + const meta = data?.ocs?.meta; + if (!meta || meta.status !== 'ok') throw new Error(meta?.message || 'Ошибка Nextcloud'); + return data.ocs.data; +} + +function validateUsername(id) { + return /^[a-zA-Z0-9._@-]{3,64}$/.test(id); +} + +function validatePassword(p) { + return typeof p === 'string' && p.length >= 8; +} + +function validateEmail(email) { + if (!email) return true; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return false; + if (ALLOWED_DOMAINS.length) { + return ALLOWED_DOMAINS.includes(email.split('@')[1]?.toLowerCase()); + } + return true; +} + +const routes = [ + { + method: 'GET', + path: '/api/config', + handler: async (_req, res) => { + sendJson(res, 200, { + enabled: ENABLED && Boolean(ADMIN_USER && ADMIN_PASS), + nextcloudUrl: NEXTCLOUD_URL, + defaultQuota: DEFAULT_QUOTA, + allowedEmailDomains: ALLOWED_DOMAINS, + }); + }, + }, + { + method: 'POST', + path: '/api/register', + handler: async (req, res) => { + if (!rateLimit(clientIp(req))) { + return sendJson(res, 429, { error: 'Слишком много попыток. Попробуйте позже.' }); + } + if (!ENABLED) return sendJson(res, 403, { error: 'Регистрация временно отключена' }); + if (!ADMIN_USER || !ADMIN_PASS) { + return sendJson(res, 503, { error: 'Сервер не настроен (REGISTRATION_ADMIN_* в .env)' }); + } + + const { username, password, email, displayName } = await readJsonBody(req); + + if (!validateUsername(username)) { + return sendJson(res, 400, { error: 'Логин: 3–64 символа, латиница, цифры, . _ @ -' }); + } + if (!validatePassword(password)) { + return sendJson(res, 400, { error: 'Пароль минимум 8 символов' }); + } + if (!validateEmail(email || '')) { + return sendJson(res, 400, { + error: ALLOWED_DOMAINS.length + ? `Разрешены только email: ${ALLOWED_DOMAINS.join(', ')}` + : 'Некорректный email', + }); + } + + try { + await ocs('POST', '/ocs/v1.php/cloud/users', { userid: username, password }); + if (DEFAULT_QUOTA) { + await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { + key: 'quota', value: DEFAULT_QUOTA, + }); + } + if (email) { + await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { + key: 'email', value: email, + }); + } + if (displayName) { + await ocs('PUT', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}`, { + key: 'displayname', value: displayName, + }); + } + if (DEFAULT_GROUP) { + await ocs('POST', `/ocs/v1.php/cloud/users/${encodeURIComponent(username)}/groups`, { + groupid: DEFAULT_GROUP, + }); + } + sendJson(res, 200, { ok: true, username, loginUrl: NEXTCLOUD_URL }); + } catch (e) { + const msg = e.message || 'Ошибка регистрации'; + if (/already exists|102/i.test(msg)) { + return sendJson(res, 409, { error: 'Пользователь с таким логином уже существует' }); + } + sendJson(res, 400, { error: msg }); + } + }, + }, +]; + +async function handleRequest(req, res) { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const matched = matchRoute(req.method, url.pathname, routes); + + if (matched) { + return matched.handler(req, res, matched.params); + } + + const file = await serveStatic(PUBLIC_DIR, url.pathname); + if (file) { + res.writeHead(200, { 'Content-Type': file.type, 'Content-Length': file.body.length }); + res.end(file.body); + return; + } + + sendJson(res, 404, { error: 'Not found' }); +} + +listen(handleRequest, { + host: HOST, + port: PORT, + http2: USE_HTTP2, + tls: TLS, +}); + +console.log(`Nextcloud → ${NEXTCLOUD_URL}`); +console.log(`Registration → ${ENABLED ? 'enabled' : 'disabled'}`); diff --git a/register-ui/start.sh b/register-ui/start.sh new file mode 100644 index 0000000..a0fef41 --- /dev/null +++ b/register-ui/start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Запуск страницы самoregистрации пользователей +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +if [[ ! -f .env ]]; then + cp .env.example .env + if [[ -f "${SCRIPT_DIR}/../docker/.env" ]]; then + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/../docker/.env" + PORT="${NEXTCLOUD_PORT:-8080}" + HTTPS="${NEXTCLOUD_HTTPS_PORT:-8443}" + if [[ -f "${SCRIPT_DIR}/../docker/.ssl-enabled" ]]; then + sed -i "s|^NEXTCLOUD_URL=.*|NEXTCLOUD_URL=https://127.0.0.1:${HTTPS}|" .env + else + sed -i "s|^NEXTCLOUD_URL=.*|NEXTCLOUD_URL=http://127.0.0.1:${PORT}|" .env + fi + if grep -q '^NEXTCLOUD_ADMIN_PASSWORD=' "${SCRIPT_DIR}/../docker/.env"; then + ADMIN_PASS="$(grep '^NEXTCLOUD_ADMIN_PASSWORD=' "${SCRIPT_DIR}/../docker/.env" | cut -d= -f2-)" + sed -i "s|^REGISTRATION_ADMIN_PASSWORD=.*|REGISTRATION_ADMIN_PASSWORD=${ADMIN_PASS}|" .env + fi + fi + echo "[WARN] Создан .env — проверьте REGISTRATION_ADMIN_PASSWORD" +fi + +command -v node >/dev/null || { echo "[ERROR] Нужен Node.js >= 18"; exit 1; } + +# shellcheck disable=SC1091 +source .env +echo "[INFO] Регистрация: http://0.0.0.0:${REGISTER_UI_PORT:-3001}" +exec node server.js