first commit

This commit is contained in:
KBAHTNET 2026-06-24 09:23:58 +03:00
commit 45b8317707
66 changed files with 4686 additions and 0 deletions

128
README.md Normal file
View File

@ -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/)

715
Recommendation.md Normal file
View File

@ -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_<ID>` | `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)

18
admin-ui/.env.example Normal file
View File

@ -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

2
admin-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

13
admin-ui/package.json Normal file
View File

@ -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"
}
}

282
admin-ui/public/app.js Normal file
View File

@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join('');
}
async function loadUsers() {
usersBody.innerHTML = '<tr><td colspan="8" class="muted center">Загрузка…</td></tr>';
const { users } = await api('/api/users');
allUsers = users;
if (!users.length) {
usersBody.innerHTML = '<tr><td colspan="8" class="muted center">Нет пользователей</td></tr>';
return;
}
usersBody.innerHTML = users.map((u) => `
<tr>
<td><strong>${escapeHtml(u.id)}</strong></td>
<td>${escapeHtml(u.displayName || '—')}</td>
<td>${escapeHtml(u.email || '—')}</td>
<td>${escapeHtml(String(u.quota || 'default'))}</td>
<td>${formatBytes(u.used)}</td>
<td>${(u.groups || []).map((g) => `<span class="group-tag">${escapeHtml(g)}</span>`).join('') || '—'}</td>
<td><span class="badge ${u.enabled ? 'on' : 'off'}">${u.enabled ? 'активен' : 'отключён'}</span></td>
<td class="row-actions">
<button class="secondary small btn-edit" data-id="${escapeHtml(u.id)}">Изменить</button>
<button class="danger small btn-del" data-id="${escapeHtml(u.id)}" ${u.id === 'admin' ? 'disabled title="Нельзя удалить admin"' : ''}>Удалить</button>
</td>
</tr>
`).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();

129
admin-ui/public/index.html Normal file
View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nextcloud — управление пользователями</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app">
<!-- Экран входа -->
<section id="login-screen" class="card login-card">
<h1>Nextcloud Admin</h1>
<p class="muted">Войдите учётной записью администратора</p>
<form id="login-form">
<label>Логин<input type="text" id="login-user" autocomplete="username" required /></label>
<label>Пароль<input type="password" id="login-pass" autocomplete="current-password" required /></label>
<button type="submit">Войти</button>
<p id="login-error" class="error hidden"></p>
</form>
</section>
<!-- Основной экран -->
<section id="main-screen" class="hidden">
<header class="topbar">
<div>
<h1>Пользователи</h1>
<p class="muted" id="server-info"></p>
</div>
<div class="topbar-actions">
<button id="btn-refresh" class="secondary">Обновить</button>
<button id="btn-create">+ Пользователь</button>
<button id="btn-logout" class="secondary">Выход</button>
</div>
</header>
<div id="global-error" class="banner error hidden"></div>
<div id="global-ok" class="banner ok hidden"></div>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>Логин</th>
<th>Имя</th>
<th>Email</th>
<th>Квота</th>
<th>Использовано</th>
<th>Группы</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="users-body">
<tr><td colspan="8" class="muted center">Загрузка…</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<!-- Модал: создать / редактировать -->
<dialog id="user-dialog">
<form id="user-form" method="dialog">
<h2 id="dialog-title">Новый пользователь</h2>
<input type="hidden" id="edit-userid" />
<label id="field-userid-wrap">
Логин
<input type="text" id="field-userid" pattern="[a-zA-Z0-9._@-]+" required />
</label>
<label>
Пароль
<input type="password" id="field-password" />
<span class="hint" id="password-hint">Обязателен для нового пользователя</span>
</label>
<label>
Отображаемое имя
<input type="text" id="field-displayname" />
</label>
<label>
Email
<input type="email" id="field-email" />
</label>
<label>
Квота (место)
<select id="field-quota">
<option value="default">По умолчанию</option>
<option value="none">Без лимита</option>
<option value="1GB">1 GB</option>
<option value="2GB">2 GB</option>
<option value="5GB">5 GB</option>
<option value="10GB">10 GB</option>
<option value="50GB">50 GB</option>
<option value="100GB">100 GB</option>
<option value="custom">Своё значение…</option>
</select>
</label>
<label id="custom-quota-wrap" class="hidden">
Своя квота (например 25GB)
<input type="text" id="field-quota-custom" placeholder="25 GB" />
</label>
<label>
Группы (права)
<select id="field-groups" multiple size="5"></select>
<span class="hint">Ctrl+клик для нескольких групп. Группа <code>admin</code> — права администратора.</span>
</label>
<label class="checkbox" id="field-enabled-wrap">
<input type="checkbox" id="field-enabled" checked />
Аккаунт активен
</label>
<div class="dialog-actions">
<button type="button" class="secondary" id="btn-cancel">Отмена</button>
<button type="submit" id="btn-save">Сохранить</button>
</div>
</form>
</dialog>
<script src="/app.js"></script>
</body>
</html>

139
admin-ui/public/styles.css Normal file
View File

@ -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; }

305
admin-ui/server.js Normal file
View File

@ -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}`);

29
admin-ui/start.sh Normal file
View File

@ -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

104
check-sudo.sh Normal file
View File

@ -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 ]]

33
docker/.env.example Normal file
View File

@ -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

6
docker/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
certs/*.pem
certs/*.key
.ssl-enabled
.env
data/
backups/

10
docker/Dockerfile.ssl Normal file
View File

@ -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

85
docker/add-user.sh Normal file
View File

@ -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

49
docker/backup.sh Normal file
View File

@ -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}"

3
docker/certs/README.txt Normal file
View File

@ -0,0 +1,3 @@
# Положите сюда сертификаты (не коммитьте в git):
# fullchain.pem
# privkey.pem

View File

@ -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
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule mod_access_compat.c>
Order Allow,Deny
Deny from all
Satisfy All
</IfModule>
# Section for Apache 2.2
<IfModule !mod_authz_core.c>
<IfModule !mod_access_compat.c>
<IfModule mod_authz_host.c>
Order Allow,Deny
Deny from all
</IfModule>
Satisfy All
</IfModule>
</IfModule>
# Section for Apache 2.2 to 2.6
<IfModule mod_autoindex.c>
IndexIgnore *
</IfModule>

View File

@ -0,0 +1,4 @@
<?php
$CONFIG = array (
'htaccess.RewriteBase' => '/',
);

View File

@ -0,0 +1,4 @@
<?php
$CONFIG = array (
'memcache.local' => '\OC\Memcache\APCu',
);

View File

@ -0,0 +1,15 @@
<?php
$CONFIG = array (
'apps_paths' => array (
0 => array (
'path' => OC::$SERVERROOT.'/apps',
'url' => '/apps',
'writable' => false,
),
1 => array (
'path' => OC::$SERVERROOT.'/custom_apps',
'url' => '/custom_apps',
'writable' => true,
),
),
);

View File

@ -0,0 +1,41 @@
<?php
$autoconfig_enabled = false;
if (getenv('SQLITE_DATABASE')) {
$AUTOCONFIG['dbtype'] = 'sqlite';
$AUTOCONFIG['dbname'] = getenv('SQLITE_DATABASE');
$autoconfig_enabled = true;
} elseif (getenv('MYSQL_DATABASE_FILE') && getenv('MYSQL_USER_FILE') && getenv('MYSQL_PASSWORD_FILE') && getenv('MYSQL_HOST')) {
$AUTOCONFIG['dbtype'] = 'mysql';
$AUTOCONFIG['dbname'] = trim(file_get_contents(getenv('MYSQL_DATABASE_FILE')));
$AUTOCONFIG['dbuser'] = trim(file_get_contents(getenv('MYSQL_USER_FILE')));
$AUTOCONFIG['dbpass'] = trim(file_get_contents(getenv('MYSQL_PASSWORD_FILE')));
$AUTOCONFIG['dbhost'] = getenv('MYSQL_HOST');
$autoconfig_enabled = true;
} elseif (getenv('MYSQL_DATABASE') && getenv('MYSQL_USER') && getenv('MYSQL_PASSWORD') && getenv('MYSQL_HOST')) {
$AUTOCONFIG['dbtype'] = 'mysql';
$AUTOCONFIG['dbname'] = getenv('MYSQL_DATABASE');
$AUTOCONFIG['dbuser'] = getenv('MYSQL_USER');
$AUTOCONFIG['dbpass'] = getenv('MYSQL_PASSWORD');
$AUTOCONFIG['dbhost'] = getenv('MYSQL_HOST');
$autoconfig_enabled = true;
} elseif (getenv('POSTGRES_DB_FILE') && getenv('POSTGRES_USER_FILE') && getenv('POSTGRES_PASSWORD_FILE') && getenv('POSTGRES_HOST')) {
$AUTOCONFIG['dbtype'] = 'pgsql';
$AUTOCONFIG['dbname'] = trim(file_get_contents(getenv('POSTGRES_DB_FILE')));
$AUTOCONFIG['dbuser'] = trim(file_get_contents(getenv('POSTGRES_USER_FILE')));
$AUTOCONFIG['dbpass'] = trim(file_get_contents(getenv('POSTGRES_PASSWORD_FILE')));
$AUTOCONFIG['dbhost'] = getenv('POSTGRES_HOST');
$autoconfig_enabled = true;
} elseif (getenv('POSTGRES_DB') && getenv('POSTGRES_USER') && getenv('POSTGRES_PASSWORD') && getenv('POSTGRES_HOST')) {
$AUTOCONFIG['dbtype'] = 'pgsql';
$AUTOCONFIG['dbname'] = getenv('POSTGRES_DB');
$AUTOCONFIG['dbuser'] = getenv('POSTGRES_USER');
$AUTOCONFIG['dbpass'] = getenv('POSTGRES_PASSWORD');
$AUTOCONFIG['dbhost'] = getenv('POSTGRES_HOST');
$autoconfig_enabled = true;
}
if ($autoconfig_enabled) {
$AUTOCONFIG['directory'] = getenv('NEXTCLOUD_DATA_DIR') ?: '/var/www/html/data';
}

View File

@ -0,0 +1,21 @@
<?php
if (getenv('REDIS_HOST')) {
$CONFIG = array(
'memcache.distributed' => '\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');
}
}

View File

@ -0,0 +1,35 @@
<?php
$overwriteHost = getenv('OVERWRITEHOST');
if ($overwriteHost) {
$CONFIG['overwritehost'] = $overwriteHost;
}
$overwriteProtocol = getenv('OVERWRITEPROTOCOL');
if ($overwriteProtocol) {
$CONFIG['overwriteprotocol'] = $overwriteProtocol;
}
$overwriteCliUrl = getenv('OVERWRITECLIURL');
if ($overwriteCliUrl) {
$CONFIG['overwrite.cli.url'] = $overwriteCliUrl;
}
$overwriteWebRoot = getenv('OVERWRITEWEBROOT');
if ($overwriteWebRoot) {
$CONFIG['overwritewebroot'] = $overwriteWebRoot;
}
$overwriteCondAddr = getenv('OVERWRITECONDADDR');
if ($overwriteCondAddr) {
$CONFIG['overwritecondaddr'] = $overwriteCondAddr;
}
$trustedProxies = getenv('TRUSTED_PROXIES');
if ($trustedProxies) {
$CONFIG['trusted_proxies'] = array_filter(array_map('trim', explode(' ', $trustedProxies)));
}
$forwardedForHeaders = getenv('FORWARDED_FOR_HEADERS');
if ($forwardedForHeaders) {
$CONFIG['forwarded_for_headers'] = array_filter(array_map('trim', explode(' ', $forwardedForHeaders)));
}

View File

@ -0,0 +1,48 @@
<?php
if (getenv('OBJECTSTORE_S3_BUCKET')) {
$use_ssl = getenv('OBJECTSTORE_S3_SSL');
$use_path = getenv('OBJECTSTORE_S3_USEPATH_STYLE');
$use_legacyauth = getenv('OBJECTSTORE_S3_LEGACYAUTH');
$autocreate = getenv('OBJECTSTORE_S3_AUTOCREATE');
$CONFIG = array(
'objectstore' => 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');
}
}

View File

@ -0,0 +1,22 @@
<?php
if (getenv('SMTP_HOST') && getenv('MAIL_FROM_ADDRESS') && getenv('MAIL_DOMAIN')) {
$CONFIG = array (
'mail_smtpmode' => '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'] = '';
}
}

View File

@ -0,0 +1,31 @@
<?php
if (getenv('OBJECTSTORE_SWIFT_URL')) {
$autocreate = getenv('OBJECTSTORE_SWIFT_AUTOCREATE');
$CONFIG = array(
'objectstore' => [
'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'),
]
]
);
}

View File

@ -0,0 +1,4 @@
<?php
$CONFIG = array (
'upgrade.disable-web' => true,
);

View File

@ -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}"

View File

@ -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

114
docker/docker-compose.yml Normal file
View File

@ -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

View File

@ -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"

74
docker/install-docker.sh Normal file
View File

@ -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

213
docker/install.sh Normal file
View File

@ -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" <<EOF
<?php
\$CONFIG = array (
'instanceid' => '${NEXTCLOUD_INSTANCEID}',
);
EOF
config_write_file "${cfg}/datadir.config.php" <<'EOF'
<?php
$CONFIG = array (
'check_data_directory_permissions' => 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 "$@"

76
docker/restore-config.sh Normal file
View File

@ -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}/"

33
docker/restore.sh Normal file
View File

@ -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] Восстановление завершено."

53
docker/setup-env.sh Normal file
View File

@ -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"

14
docker/start.sh Normal file
View File

@ -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}"

16
docker/status.sh Normal file
View File

@ -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

9
docker/stop.sh Normal file
View File

@ -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 сохранены."

View File

@ -0,0 +1,20 @@
# Apache SSL внутри контейнера Nextcloud
# Сертификаты монтируются с хоста в /etc/ssl/nextcloud/
<VirtualHost *:443>
ServerName localhost
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/ssl/nextcloud/fullchain.pem
SSLCertificateKeyFile /etc/ssl/nextcloud/privkey.pem
<Directory /var/www/html/>
Require all granted
AllowOverride All
Options FollowSymLinks MultiViews
</Directory>
ErrorLog ${APACHE_LOG_DIR}/nextcloud_ssl_error.log
CustomLog ${APACHE_LOG_DIR}/nextcloud_ssl_access.log combined
</VirtualHost>

30
docker/uninstall.sh Normal file
View File

@ -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] Готово."

2
docker/user.txt Normal file
View File

@ -0,0 +1,2 @@
Администратор: admin / BzM76OP9bz9JfhaHAn1rJ7Gi
Домен/IP: cloud.bratskih.tech

442
lib/common.sh Normal file
View File

@ -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" <<EOF
<?php
\$CONFIG = array (
'instanceid' => '${instanceid}',
);
EOF
config_write_file "${cfg}/datadir.config.php" <<'EOF'
<?php
$CONFIG = array (
'check_data_directory_permissions' => 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
}

189
lib/node-server.js Normal file
View File

@ -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));
}

33
native/.env.example Normal file
View File

@ -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

35
native/backup.sh Normal file
View File

@ -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}"

167
native/install.sh Normal file
View File

@ -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 <<EOF
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
EOF
}
configure_redis() {
log_info "Настройка Redis..."
if grep -q '^requirepass' /etc/redis/redis.conf; then
sed -i "s|^requirepass .*|requirepass ${REDIS_PASSWORD}|" /etc/redis/redis.conf
else
echo "requirepass ${REDIS_PASSWORD}" >> /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 "$@"

38
native/restore.sh Normal file
View File

@ -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] Восстановление завершено"

39
native/setup-env.sh Normal file
View File

@ -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}"

7
native/start.sh Normal file
View File

@ -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"

34
native/status.sh Normal file
View File

@ -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

7
native/stop.sh Normal file
View File

@ -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 остановлен"

View File

@ -0,0 +1,14 @@
# Шаблон для установки Nextcloud в подкаталог (/nextcloud)
# Плейсхолдеры заменяются скриптом install.sh
Alias /nextcloud "__NEXTCLOUD_INSTALL_DIR__/"
<Directory __NEXTCLOUD_INSTALL_DIR__/>
Require all granted
AllowOverride All
Options FollowSymLinks MultiViews
<IfModule mod_dav.c>
Dav off
</IfModule>
</Directory>

View File

@ -0,0 +1,20 @@
# Шаблон виртуального хоста Apache для Nextcloud
# Плейсхолдеры заменяются скриптом install.sh
<VirtualHost *:__APACHE_PORT__>
ServerName __NEXTCLOUD_DOMAIN__
DocumentRoot __NEXTCLOUD_INSTALL_DIR__
<Directory __NEXTCLOUD_INSTALL_DIR__/>
Require all granted
AllowOverride All
Options FollowSymLinks MultiViews
<IfModule mod_dav.c>
Dav off
</IfModule>
</Directory>
ErrorLog ${APACHE_LOG_DIR}/nextcloud_error.log
CustomLog ${APACHE_LOG_DIR}/nextcloud_access.log combined
</VirtualHost>

View File

@ -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

View File

@ -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

33
native/uninstall.sh Normal file
View File

@ -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 удалён"

31
register-ui/.env.example Normal file
View File

@ -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

2
register-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

13
register-ui/package.json Normal file
View File

@ -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"
}
}

71
register-ui/public/app.js Normal file
View File

@ -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');
});

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Регистрация — Nextcloud</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="card">
<header>
<h1>Создать аккаунт</h1>
<p class="muted">Облачное хранилище для файлов, календаря и синхронизации</p>
</header>
<div id="closed-banner" class="banner warn hidden">
Регистрация новых пользователей временно закрыта.
</div>
<form id="register-form">
<label>
Логин <span class="req">*</span>
<input type="text" id="username" name="username" required
pattern="[a-zA-Z0-9._@-]{3,64}" autocomplete="username"
placeholder="ivan.petrov" />
<span class="hint">Латиница, цифры, . _ @ - (3–64 символа)</span>
</label>
<label>
Отображаемое имя
<input type="text" id="displayName" name="displayName" autocomplete="name"
placeholder="Иван Петров" />
</label>
<label>
Email
<input type="email" id="email" name="email" autocomplete="email"
placeholder="you@example.com" />
<span class="hint" id="email-hint"></span>
</label>
<label>
Пароль <span class="req">*</span>
<input type="password" id="password" name="password" required minlength="8"
autocomplete="new-password" placeholder="минимум 8 символов" />
</label>
<label>
Повторите пароль <span class="req">*</span>
<input type="password" id="password2" required minlength="8"
autocomplete="new-password" />
</label>
<p id="form-error" class="error hidden"></p>
<button type="submit" id="submit-btn">Зарегистрироваться</button>
</form>
<section id="success" class="success hidden">
<h2>Готово!</h2>
<p>Аккаунт <strong id="success-user"></strong> создан.</p>
<p>Войдите в Nextcloud или установите Desktop/Mobile клиент.</p>
<a id="login-link" class="button" href="#">Открыть Nextcloud</a>
</section>
<footer class="footer">
<p class="muted">Уже есть аккаунт? <a id="footer-login" href="#">Войти</a></p>
</footer>
</main>
<script src="/app.js"></script>
</body>
</html>

View File

@ -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); }

180
register-ui/server.js Normal file
View File

@ -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'}`);

33
register-ui/start.sh Normal file
View File

@ -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