first commit
This commit is contained in:
commit
45b8317707
128
README.md
Normal file
128
README.md
Normal 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
715
Recommendation.md
Normal 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
18
admin-ui/.env.example
Normal 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
2
admin-ui/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
13
admin-ui/package.json
Normal file
13
admin-ui/package.json
Normal 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
282
admin-ui/public/app.js
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
129
admin-ui/public/index.html
Normal 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
139
admin-ui/public/styles.css
Normal 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
305
admin-ui/server.js
Normal 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
29
admin-ui/start.sh
Normal 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
104
check-sudo.sh
Normal 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
33
docker/.env.example
Normal 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
6
docker/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
certs/*.pem
|
||||
certs/*.key
|
||||
.ssl-enabled
|
||||
.env
|
||||
data/
|
||||
backups/
|
||||
10
docker/Dockerfile.ssl
Normal file
10
docker/Dockerfile.ssl
Normal 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
85
docker/add-user.sh
Normal 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
49
docker/backup.sh
Normal 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
3
docker/certs/README.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# Положите сюда сертификаты (не коммитьте в git):
|
||||
# fullchain.pem
|
||||
# privkey.pem
|
||||
28
docker/config.defaults/.htaccess
Normal file
28
docker/config.defaults/.htaccess
Normal 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>
|
||||
4
docker/config.defaults/apache-pretty-urls.config.php
Normal file
4
docker/config.defaults/apache-pretty-urls.config.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php
|
||||
$CONFIG = array (
|
||||
'htaccess.RewriteBase' => '/',
|
||||
);
|
||||
4
docker/config.defaults/apcu.config.php
Normal file
4
docker/config.defaults/apcu.config.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php
|
||||
$CONFIG = array (
|
||||
'memcache.local' => '\OC\Memcache\APCu',
|
||||
);
|
||||
15
docker/config.defaults/apps.config.php
Normal file
15
docker/config.defaults/apps.config.php
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
41
docker/config.defaults/autoconfig.php
Normal file
41
docker/config.defaults/autoconfig.php
Normal 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';
|
||||
}
|
||||
21
docker/config.defaults/redis.config.php
Normal file
21
docker/config.defaults/redis.config.php
Normal 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');
|
||||
}
|
||||
}
|
||||
35
docker/config.defaults/reverse-proxy.config.php
Normal file
35
docker/config.defaults/reverse-proxy.config.php
Normal 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)));
|
||||
}
|
||||
48
docker/config.defaults/s3.config.php
Normal file
48
docker/config.defaults/s3.config.php
Normal 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');
|
||||
}
|
||||
}
|
||||
22
docker/config.defaults/smtp.config.php
Normal file
22
docker/config.defaults/smtp.config.php
Normal 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'] = '';
|
||||
}
|
||||
}
|
||||
31
docker/config.defaults/swift.config.php
Normal file
31
docker/config.defaults/swift.config.php
Normal 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'),
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
4
docker/config.defaults/upgrade-disable-web.config.php
Normal file
4
docker/config.defaults/upgrade-disable-web.config.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php
|
||||
$CONFIG = array (
|
||||
'upgrade.disable-web' => true,
|
||||
);
|
||||
28
docker/disable-docker-ssl.sh
Normal file
28
docker/disable-docker-ssl.sh
Normal 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}"
|
||||
16
docker/docker-compose.ssl.yml
Normal file
16
docker/docker-compose.ssl.yml
Normal 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
114
docker/docker-compose.yml
Normal 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
|
||||
96
docker/enable-docker-ssl.sh
Normal file
96
docker/enable-docker-ssl.sh
Normal 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
74
docker/install-docker.sh
Normal 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
213
docker/install.sh
Normal 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
76
docker/restore-config.sh
Normal 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
33
docker/restore.sh
Normal 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
53
docker/setup-env.sh
Normal 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
14
docker/start.sh
Normal 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
16
docker/status.sh
Normal 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
9
docker/stop.sh
Normal 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 сохранены."
|
||||
20
docker/templates/apache-ssl-inside.conf
Normal file
20
docker/templates/apache-ssl-inside.conf
Normal 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
30
docker/uninstall.sh
Normal 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
2
docker/user.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Администратор: admin / BzM76OP9bz9JfhaHAn1rJ7Gi
|
||||
Домен/IP: cloud.bratskih.tech
|
||||
442
lib/common.sh
Normal file
442
lib/common.sh
Normal 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
189
lib/node-server.js
Normal 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
33
native/.env.example
Normal 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
35
native/backup.sh
Normal 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
167
native/install.sh
Normal 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
38
native/restore.sh
Normal 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
39
native/setup-env.sh
Normal 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
7
native/start.sh
Normal 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
34
native/status.sh
Normal 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
7
native/stop.sh
Normal 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 остановлен"
|
||||
14
native/templates/apache-subdir.conf.template
Normal file
14
native/templates/apache-subdir.conf.template
Normal 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>
|
||||
20
native/templates/apache-vhost.conf.template
Normal file
20
native/templates/apache-vhost.conf.template
Normal 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>
|
||||
6
native/templates/cron-nextcloud.template
Normal file
6
native/templates/cron-nextcloud.template
Normal 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
|
||||
13
native/templates/php-nextcloud.ini.template
Normal file
13
native/templates/php-nextcloud.ini.template
Normal 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
33
native/uninstall.sh
Normal 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
31
register-ui/.env.example
Normal 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
2
register-ui/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
13
register-ui/package.json
Normal file
13
register-ui/package.json
Normal 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
71
register-ui/public/app.js
Normal 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');
|
||||
});
|
||||
72
register-ui/public/index.html
Normal file
72
register-ui/public/index.html
Normal 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>
|
||||
110
register-ui/public/styles.css
Normal file
110
register-ui/public/styles.css
Normal 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
180
register-ui/server.js
Normal 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
33
register-ui/start.sh
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user