feat: Add Nanaxe server with Docker, GitHub Actions CI/CD, and auto-deployment
- Add complete server infrastructure with Gitea, API, PostgreSQL - Implement Obsidian-compatible markdown mindmap converter - Add Flutter service for server synchronization - Configure GitHub Actions for automated build and deploy - Add production docker-compose with health checks - Include VPS setup and deployment scripts - Add comprehensive deployment documentation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Nanaxe Server Configuration
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# Domain configuration
|
||||
GITEA_DOMAIN=your-domain.com
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
|
||||
# WebDAV (optional)
|
||||
WEBDAV_USER=admin
|
||||
WEBDAV_PASSWORD=secure_password_here
|
||||
|
||||
# Database (change in production!)
|
||||
POSTGRES_PASSWORD=change_this_password
|
||||
|
||||
# API Configuration
|
||||
API_PORT=8080
|
||||
@@ -0,0 +1,369 @@
|
||||
# 🚀 Развёртывание Nanaxe Server на Proxmox VPS
|
||||
|
||||
## 📋 Содержание
|
||||
1. [Подготовка VPS](#подготовка-vps)
|
||||
2. [Установка Docker](#установка-docker)
|
||||
3. [Настройка сервера](#настройка-сервера)
|
||||
4. [Запуск](#запуск)
|
||||
5. [Настройка домена и SSL](#настройка-домена-и-ssl)
|
||||
6. [Подключение Flutter-приложения](#подключение-flutter-приложения)
|
||||
|
||||
---
|
||||
|
||||
## Подготовка VPS
|
||||
|
||||
### 1. Создание LXC контейнера в Proxmox
|
||||
|
||||
```bash
|
||||
# На хосте Proxmox создайте контейнер
|
||||
pct create 100 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \
|
||||
--hostname nanaxe-server \
|
||||
--memory 2048 \
|
||||
--cores 2 \
|
||||
--rootfs local-lvm:10 \
|
||||
--net0 name=eth0,bridge=vmbr0,ip=dhcp
|
||||
|
||||
# Запустите контейнер
|
||||
pct start 100
|
||||
|
||||
# Войдите в контейнер
|
||||
pct enter 100
|
||||
```
|
||||
|
||||
### 2. Обновление системы
|
||||
|
||||
```bash
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl wget git nano htop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Установка Docker
|
||||
|
||||
```bash
|
||||
# Установка Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sh get-docker.sh
|
||||
|
||||
# Добавление пользователя в группу docker
|
||||
usermod -aG docker $USER
|
||||
|
||||
# Установка Docker Compose
|
||||
apt install -y docker-compose-plugin
|
||||
|
||||
# Проверка
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка сервера
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone <your-repo-url> nanaxe-server
|
||||
cd nanaxe-server/server
|
||||
```
|
||||
|
||||
### 2. Настройка переменных окружения
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Заполните файл:
|
||||
|
||||
```env
|
||||
# Domain configuration
|
||||
GITEA_DOMAIN=your-domain.com
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
|
||||
# Security (сгенерируйте случайный ключ)
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
|
||||
# WebDAV (optional)
|
||||
WEBDAV_USER=admin
|
||||
WEBDAV_PASSWORD=your_secure_password
|
||||
|
||||
# Database (обязательно измените!)
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 24)
|
||||
```
|
||||
|
||||
### 3. Создание директорий
|
||||
|
||||
```bash
|
||||
mkdir -p api/logs
|
||||
mkdir -p nginx/html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Запуск
|
||||
|
||||
### 1. Первый запуск
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2. Проверка статуса
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 3. Инициализация Gitea
|
||||
|
||||
Откройте `http://your-server-ip:3000` и выполните первоначальную настройку:
|
||||
|
||||
1. Установите базу данных (уже настроена PostgreSQL)
|
||||
2. Создайте администратора
|
||||
3. Создайте API токен в Settings → Applications
|
||||
4. Добавьте токен в `.env` файл
|
||||
|
||||
```bash
|
||||
# Перезапустите сервисы
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка домена и SSL
|
||||
|
||||
### 1. Настройка DNS
|
||||
|
||||
Укажите A-записи:
|
||||
- `your-domain.com` → IP сервера
|
||||
- `git.your-domain.com` → IP сервера
|
||||
- `api.your-domain.com` → IP сервера
|
||||
|
||||
### 2. Установка Certbot
|
||||
|
||||
```bash
|
||||
apt install -y certbot
|
||||
|
||||
# Получение сертификатов
|
||||
certbot certonly --standalone -d your-domain.com -d git.your-domain.com -d api.your-domain.com
|
||||
```
|
||||
|
||||
### 3. Настройка Nginx
|
||||
|
||||
Отредактируйте `nginx/nginx.conf`:
|
||||
|
||||
```nginx
|
||||
# Раскомментируйте HTTPS server section
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# ... rest of config
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Обновление docker-compose.yml
|
||||
|
||||
Добавьте volume для SSL сертификатов:
|
||||
|
||||
```yaml
|
||||
nginx:
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/html:/usr/share/nginx/html:ro
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro # Добавьте это
|
||||
```
|
||||
|
||||
### 5. Перезапуск
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Подключение Flutter-приложения
|
||||
|
||||
### 1. Настройка сервера в приложении
|
||||
|
||||
```dart
|
||||
import 'package:nanaxe_crm/core/nanaxe_server_service.dart';
|
||||
|
||||
final serverService = NanaxeServerService();
|
||||
|
||||
// При инициализации
|
||||
await serverService.initialize();
|
||||
|
||||
// Если не настроен - настройте
|
||||
if (!serverService.isConfigured) {
|
||||
await serverService.configure(
|
||||
'https://api.your-domain.com',
|
||||
);
|
||||
}
|
||||
|
||||
// Проверка соединения
|
||||
final isConnected = await serverService.checkConnection();
|
||||
```
|
||||
|
||||
### 2. Авторизация
|
||||
|
||||
```dart
|
||||
// Регистрация
|
||||
await serverService.register(
|
||||
'user@example.com',
|
||||
'password',
|
||||
'username',
|
||||
);
|
||||
|
||||
// Или вход
|
||||
await serverService.login(
|
||||
'user@example.com',
|
||||
'password',
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Работа с mindmaps
|
||||
|
||||
```dart
|
||||
// Получение списка
|
||||
final mindmaps = await serverService.getMindMaps();
|
||||
|
||||
// Создание
|
||||
final newMap = await serverService.createMindMap(
|
||||
title: 'My Project',
|
||||
description: 'Project planning',
|
||||
nodes: [
|
||||
{'id': '1', 'title': 'Root', 'level': 0, 'parentId': null},
|
||||
{'id': '2', 'title': 'Task 1', 'level': 1, 'parentId': '1'},
|
||||
],
|
||||
);
|
||||
|
||||
// Синхронизация с Gitea
|
||||
await serverService.pushToGitea(commitMessage: 'Updated project plan');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Обслуживание
|
||||
|
||||
### Обновление
|
||||
|
||||
```bash
|
||||
cd /opt/nanaxe-server/server
|
||||
git pull
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Бэкап
|
||||
|
||||
```bash
|
||||
# Бэкап данных
|
||||
tar -czf backup-$(date +%Y%m%d).tar.gz /opt/nanaxe-server/server
|
||||
|
||||
# Или бэкап volumes
|
||||
docker run --rm -v nanaxe-server_mindmap-data:/data -v $(pwd):/backup alpine tar czf /backup/mindmaps-backup.tar.gz -C /data .
|
||||
```
|
||||
|
||||
### Мониторинг
|
||||
|
||||
```bash
|
||||
# Логи
|
||||
docker compose logs -f
|
||||
|
||||
# Статистика
|
||||
docker stats
|
||||
|
||||
# Дисковое пространство
|
||||
df -h
|
||||
docker system df
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Доступ к сервисам
|
||||
|
||||
После развёртывания:
|
||||
|
||||
| Сервис | URL | Описание |
|
||||
|--------|-----|----------|
|
||||
| Web UI | `http://your-domain.com` | Главная страница |
|
||||
| Gitea | `http://your-domain.com:3000` | Git репозиторий |
|
||||
| API | `http://your-domain.com:8080` | REST API |
|
||||
| WebDAV | `http://your-domain.com:8081` | Прямой доступ к файлам |
|
||||
|
||||
---
|
||||
|
||||
## 📱 Obsidian Интеграция
|
||||
|
||||
### 1. Git Sync плагин
|
||||
|
||||
Установите плагин **Obsidian Git** и настройте:
|
||||
|
||||
```bash
|
||||
# В настройках плагина
|
||||
Remote URL: http://your-domain.com:3000/username/mindmaps.git
|
||||
```
|
||||
|
||||
### 2. Прямой доступ через WebDAV
|
||||
|
||||
Установите плагин **Remote Save** и настройте WebDAV:
|
||||
|
||||
```
|
||||
URL: http://your-domain.com:8081
|
||||
Username: admin
|
||||
Password: your_secure_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Устранение неполадок
|
||||
|
||||
### Проблема: Порты заняты
|
||||
|
||||
```bash
|
||||
# Проверка занятых портов
|
||||
netstat -tlnp | grep -E '3000|8080|80'
|
||||
|
||||
# Измените порты в docker-compose.yml
|
||||
```
|
||||
|
||||
### Проблема: Permission denied
|
||||
|
||||
```bash
|
||||
# Проверьте права
|
||||
ls -la /opt/nanaxe-server/server
|
||||
|
||||
# Исправьте
|
||||
chown -R $USER:$USER /opt/nanaxe-server/server
|
||||
```
|
||||
|
||||
### Проблема: Контейнеры не запускаются
|
||||
|
||||
```bash
|
||||
# Проверьте логи
|
||||
docker compose logs
|
||||
|
||||
# Пересоберите
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Дополнительно
|
||||
|
||||
- [Gitea Documentation](https://docs.gitea.io/)
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [Nginx Documentation](https://nginx.org/en/docs/)
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install git for repository operations
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy app source
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/mindmaps /app/logs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Start the server
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "nanaxe-api-server",
|
||||
"version": "1.0.0",
|
||||
"description": "API server for Nanaxe CRM mindmap synchronization",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"axios": "^1.6.2",
|
||||
"simple-git": "^3.21.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"yaml": "^2.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"jest": "^29.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const mindmapRoutes = require('./routes/mindmap');
|
||||
const syncRoutes = require('./routes/sync');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { errorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8080;
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Logging
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/mindmaps', mindmapRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Nanaxe API Server running on port ${PORT}`);
|
||||
console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`);
|
||||
console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key';
|
||||
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const generateToken = (user) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { authenticateToken, generateToken };
|
||||
@@ -0,0 +1,33 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Default error
|
||||
let statusCode = err.statusCode || 500;
|
||||
let message = err.message || 'Internal Server Error';
|
||||
|
||||
// Handle specific error types
|
||||
if (err.name === 'ValidationError') {
|
||||
statusCode = 400;
|
||||
message = 'Validation Error: ' + message;
|
||||
} else if (err.name === 'UnauthorizedError') {
|
||||
statusCode = 401;
|
||||
message = 'Unauthorized';
|
||||
} else if (err.name === 'ForbiddenError') {
|
||||
statusCode = 403;
|
||||
message = 'Forbidden';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
statusCode = 404;
|
||||
message = 'Resource not found';
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message,
|
||||
status: statusCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: req.path
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { errorHandler };
|
||||
@@ -0,0 +1,97 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generateToken } = require('../middleware/auth');
|
||||
|
||||
// Mock user storage (в продакшене использовать базу данных)
|
||||
const users = new Map();
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, username } = req.body;
|
||||
|
||||
if (!email || !password || !username) {
|
||||
return res.status(400).json({ error: 'Email, password and username are required' });
|
||||
}
|
||||
|
||||
if (users.has(email)) {
|
||||
return res.status(409).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
const userId = Date.now().toString();
|
||||
const user = {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
password, // В продакшене хешировать!
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
users.set(email, user);
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User registered successfully',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
const user = users.get(email);
|
||||
|
||||
if (!user || user.password !== password) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate token
|
||||
router.get('/validate', async (req, res) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ valid: false, error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-super-secret-jwt-key');
|
||||
res.json({ valid: true, user: decoded });
|
||||
} catch (error) {
|
||||
res.status(403).json({ valid: false, error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,266 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
|
||||
// Ensure storage directory exists
|
||||
fs.ensureDirSync(STORAGE_PATH);
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps - List all mindmaps
|
||||
*/
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
const mindmaps = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(userDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content);
|
||||
|
||||
mindmaps.push({
|
||||
id: mindmap.id,
|
||||
title: mindmap.title,
|
||||
description: mindmap.description,
|
||||
nodeCount: mindmap.nodes.length,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
filename: file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt desc
|
||||
mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
res.json({
|
||||
count: mindmaps.length,
|
||||
mindmaps
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to list mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id - Get single mindmap
|
||||
*/
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps - Create new mindmap
|
||||
*/
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { title, description, nodes = [], settings = {} } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const mindmap = {
|
||||
id,
|
||||
title,
|
||||
description: description || '',
|
||||
nodes: nodes.length > 0 ? nodes : [{
|
||||
id: uuidv4(),
|
||||
title: 'Root',
|
||||
level: 0,
|
||||
parentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}],
|
||||
settings,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const markdown = MarkdownConverter.toMarkdown(mindmap);
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.status(201).json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error creating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to create mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/mindmaps/:id - Update mindmap
|
||||
*/
|
||||
router.put('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const updates = req.body;
|
||||
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
// Read existing
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const existing = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
// Merge updates
|
||||
const updated = {
|
||||
...existing,
|
||||
...updates,
|
||||
id, // Preserve ID
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save
|
||||
const markdown = MarkdownConverter.toMarkdown(updated);
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to update mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/mindmaps/:id - Delete mindmap
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
await fs.remove(filePath);
|
||||
|
||||
res.json({ message: 'Mindmap deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to delete mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown
|
||||
*/
|
||||
router.get('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error('Error getting markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to get markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps/:id/markdown - Update mindmap from markdown
|
||||
*/
|
||||
router.post('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const { markdown } = req.body;
|
||||
|
||||
if (!markdown) {
|
||||
return res.status(400).json({ error: 'Markdown content is required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
// Parse and return
|
||||
const mindmap = MarkdownConverter.fromMarkdown(markdown, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error saving markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to save markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format
|
||||
*/
|
||||
router.get('/:id/markmap', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
const markmap = MarkdownConverter.toMarkmap(mindmap);
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.send(markmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting markmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get markmap' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,268 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const simpleGit = require('simple-git');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/push - Push mindmaps to Gitea repository
|
||||
*/
|
||||
router.post('/gitea/push', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName, commitMessage } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Initialize or open git repo
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
await git.init();
|
||||
|
||||
// Configure git
|
||||
await git.addConfig('user.email', req.user.email || 'user@nanaxe.app');
|
||||
await git.addConfig('user.name', req.user.username || 'Nanaxe User');
|
||||
}
|
||||
|
||||
// Copy all mindmaps to repo
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
await fs.copy(
|
||||
path.join(userDir, file),
|
||||
path.join(repoDir, file)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit changes
|
||||
await git.add('.');
|
||||
const status = await git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
return res.json({ message: 'No changes to commit', synced: false });
|
||||
}
|
||||
|
||||
await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM');
|
||||
|
||||
// Push to Gitea if configured
|
||||
if (GITEA_TOKEN) {
|
||||
const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
|
||||
try {
|
||||
// Check if remote exists
|
||||
const remotes = await git.getRemotes(true);
|
||||
const originExists = remotes.some(r => r.name === 'origin');
|
||||
|
||||
if (!originExists) {
|
||||
await git.addRemote('origin', remoteUrl);
|
||||
}
|
||||
|
||||
await git.push('origin', 'master');
|
||||
} catch (pushError) {
|
||||
console.error('Push error:', pushError);
|
||||
// Continue even if push fails - local commit is successful
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps synced successfully',
|
||||
synced: true,
|
||||
filesCommitted: status.files.length,
|
||||
commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing to Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to sync mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository
|
||||
*/
|
||||
router.post('/gitea/pull', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!GITEA_TOKEN) {
|
||||
return res.status(400).json({ error: 'Gitea not configured' });
|
||||
}
|
||||
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
|
||||
// Clone from Gitea
|
||||
const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
await git.clone(cloneUrl, repoDir);
|
||||
} else {
|
||||
// Pull latest
|
||||
await git.pull('origin', 'master');
|
||||
}
|
||||
|
||||
// Copy files from repo to storage
|
||||
const files = await fs.readdir(repoDir);
|
||||
let importedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md') && !file.startsWith('.')) {
|
||||
await fs.copy(
|
||||
path.join(repoDir, file),
|
||||
path.join(userDir, file),
|
||||
{ overwrite: true }
|
||||
);
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps pulled successfully',
|
||||
imported: importedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error pulling from Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to pull mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sync/status - Get sync status
|
||||
*/
|
||||
router.get('/status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
const status = {
|
||||
hasLocalData: await fs.pathExists(userDir),
|
||||
hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')),
|
||||
giteaConfigured: !!GITEA_TOKEN,
|
||||
lastSync: null,
|
||||
pendingChanges: 0
|
||||
};
|
||||
|
||||
if (status.hasGitRepo) {
|
||||
const git = simpleGit(repoDir);
|
||||
const gitStatus = await git.status();
|
||||
status.pendingChanges = gitStatus.files.length;
|
||||
|
||||
// Get last commit date
|
||||
try {
|
||||
const log = await git.log({ maxCount: 1 });
|
||||
if (log.latest) {
|
||||
status.lastSync = log.latest.date;
|
||||
}
|
||||
} catch (e) {
|
||||
// No commits yet
|
||||
}
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting sync status:', error);
|
||||
res.status(500).json({ error: 'Failed to get sync status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/export - Export all mindmaps as zip
|
||||
*/
|
||||
router.post('/export', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Create temporary zip
|
||||
const archiver = require('archiver');
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"');
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
archive.file(path.join(userDir, file), { name: file });
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Error exporting mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to export mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/import - Import mindmaps from files
|
||||
*/
|
||||
router.post('/import', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { files } = req.body; // Array of { filename, content }
|
||||
|
||||
if (!files || !Array.isArray(files)) {
|
||||
return res.status(400).json({ error: 'Files array required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (!file.filename.endsWith('.md')) {
|
||||
results.errors.push({ file: file.filename, error: 'Not a markdown file' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate markdown
|
||||
const mindmap = MarkdownConverter.fromMarkdown(file.content);
|
||||
|
||||
// Save
|
||||
const filePath = path.join(userDir, file.filename);
|
||||
await fs.writeFile(filePath, file.content, 'utf-8');
|
||||
|
||||
results.imported++;
|
||||
} catch (error) {
|
||||
results.errors.push({ file: file.filename, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error importing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to import mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Конвертер между MindMap форматом и Markdown (Obsidian-совместимый)
|
||||
*
|
||||
* Obsidian MindMap формат:
|
||||
* - Заголовки (# ## ###) = уровни узлов
|
||||
* - Списки (- *) = дочерние узлы
|
||||
* - [[ссылки]] = внутренние связи
|
||||
* - Теги #тег = метки узлов
|
||||
*/
|
||||
|
||||
const YAML = require('yaml');
|
||||
|
||||
class MarkdownConverter {
|
||||
/**
|
||||
* Конвертирует MindMap из JSON в Markdown
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markdown строка
|
||||
*/
|
||||
static toMarkdown(mindmap) {
|
||||
const { id, title, description, nodes, settings = {} } = mindmap;
|
||||
|
||||
// Frontmatter с метаданными
|
||||
const frontmatter = {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
createdAt: mindmap.createdAt,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
...settings
|
||||
};
|
||||
|
||||
let markdown = '---\n';
|
||||
markdown += YAML.stringify(frontmatter);
|
||||
markdown += '---\n\n';
|
||||
|
||||
// Заголовок mindmap
|
||||
markdown += `# ${title}\n\n`;
|
||||
|
||||
if (description) {
|
||||
markdown += `> ${description}\n\n`;
|
||||
}
|
||||
|
||||
// Конвертируем узлы в иерархию
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkdown(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markdown
|
||||
*/
|
||||
static _nodeToMarkdown(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = '';
|
||||
|
||||
// Заголовок или список в зависимости от уровня
|
||||
if (level === 0) {
|
||||
markdown += `## ${node.title}\n`;
|
||||
} else if (level === 1) {
|
||||
markdown += `### ${node.title}\n`;
|
||||
} else if (level === 2) {
|
||||
markdown += `#### ${node.title}\n`;
|
||||
} else {
|
||||
markdown += `${indent}- ${node.title}\n`;
|
||||
}
|
||||
|
||||
// Добавляем метаданные узла
|
||||
if (node.taskId) {
|
||||
markdown += `${indent} - Task: [[${node.taskId}]]\n`;
|
||||
}
|
||||
if (node.color) {
|
||||
markdown += `${indent} - Color: \`${node.color}\`\n`;
|
||||
}
|
||||
|
||||
// Находим дочерние узлы
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkdown(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
markdown += '\n';
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит Markdown в MindMap JSON
|
||||
* @param {string} markdown - Markdown строка
|
||||
* @returns {Object} Объект mindmap
|
||||
*/
|
||||
static fromMarkdown(markdown, existingId = null) {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
// Парсим frontmatter
|
||||
let frontmatter = {};
|
||||
let contentStart = 0;
|
||||
|
||||
if (lines[0] === '---') {
|
||||
const endIndex = lines.indexOf('---', 1);
|
||||
if (endIndex !== -1) {
|
||||
const yamlContent = lines.slice(1, endIndex).join('\n');
|
||||
frontmatter = YAML.parse(yamlContent) || {};
|
||||
contentStart = endIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим контент
|
||||
const content = lines.slice(contentStart).join('\n');
|
||||
const nodes = this._parseNodes(content);
|
||||
|
||||
return {
|
||||
id: existingId || frontmatter.id || this._generateId(),
|
||||
title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap',
|
||||
description: frontmatter.description || '',
|
||||
nodes,
|
||||
settings: frontmatter.settings || {},
|
||||
createdAt: frontmatter.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит узлы из Markdown контента
|
||||
*/
|
||||
static _parseNodes(content) {
|
||||
const lines = content.split('\n');
|
||||
const nodes = [];
|
||||
const nodeStack = []; // Стек для отслеживания родительских узлов
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Определяем уровень и тип
|
||||
let level = 0;
|
||||
let title = '';
|
||||
let parentId = null;
|
||||
|
||||
// Заголовки H2-H4
|
||||
const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3
|
||||
title = headingMatch[2].trim();
|
||||
}
|
||||
// Списки
|
||||
else if (trimmed.match(/^[-*]\s+/)) {
|
||||
const indent = line.search(/\S/);
|
||||
level = Math.floor(indent / 2) + 3;
|
||||
title = trimmed.replace(/^[-*]\s+/, '');
|
||||
}
|
||||
// Пропускаем H1 (это заголовок карты)
|
||||
else if (trimmed.startsWith('# ')) {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Находим родителя
|
||||
while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
|
||||
nodeStack.pop();
|
||||
}
|
||||
|
||||
if (nodeStack.length > 0) {
|
||||
parentId = nodeStack[nodeStack.length - 1].id;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: this._generateId(),
|
||||
title,
|
||||
level,
|
||||
parentId,
|
||||
color: this._extractColor(trimmed),
|
||||
taskId: this._extractTaskId(trimmed),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
nodeStack.push({ id: node.id, level });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает цвет из строки
|
||||
*/
|
||||
static _extractColor(line) {
|
||||
const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/);
|
||||
return colorMatch ? `#${colorMatch[1]}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает ID задачи из ссылки
|
||||
*/
|
||||
static _extractTaskId(line) {
|
||||
const taskMatch = line.match(/\[\[([^\]]+)\]\]/);
|
||||
return taskMatch ? taskMatch[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает заголовок из контента
|
||||
*/
|
||||
static _extractTitle(content) {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный ID
|
||||
*/
|
||||
static _generateId() {
|
||||
return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует MindMap в формат Markmap (для визуализации)
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markmap Markdown
|
||||
*/
|
||||
static toMarkmap(mindmap) {
|
||||
const { title, nodes } = mindmap;
|
||||
|
||||
let markdown = `# ${title}\n\n`;
|
||||
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkmap(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markmap формат
|
||||
*/
|
||||
static _nodeToMarkmap(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = `${indent}- ${node.title}\n`;
|
||||
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkmap(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MarkdownConverter;
|
||||
@@ -0,0 +1,251 @@
|
||||
version: '3.8'
|
||||
|
||||
# Production Docker Compose Configuration
|
||||
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
# ==========================================
|
||||
# Gitea - Production Configuration
|
||||
# ==========================================
|
||||
gitea:
|
||||
image: gitea/gitea:latest-rootless
|
||||
container_name: nanaxe-gitea
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=postgres:5432
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
|
||||
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}
|
||||
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN}
|
||||
- GITEA__server__SSH_PORT=222
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__security__SECRET_KEY=${GITEA_SECRET_KEY}
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||
- GITEA__log__MODE=console
|
||||
- GITEA__log__LEVEL=Info
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
# ==========================================
|
||||
# PostgreSQL - Production
|
||||
# ==========================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: nanaxe-postgres
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./backups:/backups
|
||||
environment:
|
||||
- POSTGRES_USER=gitea
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=gitea
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gitea"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.1'
|
||||
memory: 64M
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe API - Production
|
||||
# ==========================================
|
||||
nanaxe-api:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY:-nanaxe}/nanaxe-api:latest
|
||||
container_name: nanaxe-api
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/app/mindmaps
|
||||
- api-logs:/app/logs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||
- LOG_LEVEL=info
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.1'
|
||||
memory: 64M
|
||||
replicas: 1
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
failure_action: rollback
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
failure_action: pause
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
|
||||
# ==========================================
|
||||
# Nginx - Production with SSL
|
||||
# ==========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nanaxe-nginx
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/html:/usr/share/nginx/html:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- certbot-data:/etc/letsencrypt:ro
|
||||
- certbot-www:/var/www/certbot:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
depends_on:
|
||||
- gitea
|
||||
- nanaxe-api
|
||||
|
||||
# ==========================================
|
||||
# Certbot - SSL Certificates
|
||||
# ==========================================
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: nanaxe-certbot
|
||||
restart: "no"
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- certbot-data:/etc/letsencrypt
|
||||
- certbot-www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
profiles:
|
||||
- ssl
|
||||
|
||||
# ==========================================
|
||||
# Watchtower - Auto-update containers
|
||||
# ==========================================
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: nanaxe-watchtower
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
- WATCHTOWER_POLL_INTERVAL=3600
|
||||
- WATCHTOWER_INCLUDE_STOPPED=true
|
||||
- WATCHTOWER_REVIVE_STOPPED=false
|
||||
- WATCHTOWER_ROLLING_RESTART=true
|
||||
- WATCHTOWER_LABEL_ENABLE=true
|
||||
command: --interval 3600 --cleanup
|
||||
profiles:
|
||||
- auto-update
|
||||
|
||||
# ==========================================
|
||||
# Redis - Caching (optional)
|
||||
# ==========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: nanaxe-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
profiles:
|
||||
- cache
|
||||
|
||||
networks:
|
||||
nanaxe-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
driver: local
|
||||
postgres-data:
|
||||
driver: local
|
||||
mindmap-data:
|
||||
driver: local
|
||||
api-logs:
|
||||
driver: local
|
||||
certbot-data:
|
||||
driver: local
|
||||
certbot-www:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,116 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Gitea - Self-hosted Git server для хранения markdown-файлов
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: nanaxe-gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=postgres:5432
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=gitea_password
|
||||
- GITEA__server__DOMAIN=${GITEA_DOMAIN:-localhost}
|
||||
- GITEA__server__ROOT_URL=http://${GITEA_DOMAIN:-localhost}:3000
|
||||
- GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN:-localhost}
|
||||
- GITEA__server__SSH_PORT=222
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "222:22"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
# PostgreSQL для Gitea
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: nanaxe-postgres
|
||||
environment:
|
||||
- POSTGRES_USER=gitea
|
||||
- POSTGRES_PASSWORD=gitea_password
|
||||
- POSTGRES_DB=gitea
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
# Nanaxe API Server - для синхронизации mindmaps
|
||||
nanaxe-api:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanaxe-api
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/app/mindmaps
|
||||
- ./api/logs:/app/logs
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- gitea
|
||||
- postgres
|
||||
|
||||
# Nginx - Reverse Proxy и статические файлы
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nanaxe-nginx
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/html:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
- gitea
|
||||
- nanaxe-api
|
||||
|
||||
# (Опционально) WebDAV сервер для прямого доступа к файлам
|
||||
webdav:
|
||||
image: bytemark/webdav
|
||||
container_name: nanaxe-webdav
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/var/lib/dav
|
||||
environment:
|
||||
- AUTH_TYPE=Basic
|
||||
- USERNAME=${WEBDAV_USER:-admin}
|
||||
- PASSWORD=${WEBDAV_PASSWORD:-admin}
|
||||
ports:
|
||||
- "8081:80"
|
||||
|
||||
networks:
|
||||
nanaxe-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
postgres-data:
|
||||
mindmap-data:
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nanaxe Server</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.services {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.service-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.service-card p {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.service-card a {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.service-card a:hover {
|
||||
background: #f0f0f0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #4ade80;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 Nanaxe Server</h1>
|
||||
<p>Self-hosted mindmap synchronization server</p>
|
||||
|
||||
<div class="services">
|
||||
<div class="service-card">
|
||||
<h3>📚 Git Repository</h3>
|
||||
<p>Access your mindmaps via Git</p>
|
||||
<a href="http://localhost:3000" target="_blank">Open Gitea</a>
|
||||
</div>
|
||||
|
||||
<div class="service-card">
|
||||
<h3>🔌 API</h3>
|
||||
<p>REST API for mindmap operations</p>
|
||||
<a href="http://localhost:8080/health" target="_blank">Check API</a>
|
||||
</div>
|
||||
|
||||
<div class="service-card">
|
||||
<h3>📱 WebDAV</h3>
|
||||
<p>Direct file access</p>
|
||||
<a href="http://localhost:8081" target="_blank">Open WebDAV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span class="status-indicator"></span>
|
||||
<span>Server is running</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# Upstream servers
|
||||
upstream gitea {
|
||||
server gitea:3000;
|
||||
}
|
||||
|
||||
upstream nanaxe_api {
|
||||
server nanaxe-api:8080;
|
||||
}
|
||||
|
||||
# HTTP server - redirects to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# For Let's Encrypt challenges
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Uncomment for HTTPS redirect
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
# Or proxy to services for local development
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# API Server
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.*;
|
||||
|
||||
location / {
|
||||
proxy_pass http://nanaxe_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
|
||||
# Gitea Server
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.*;
|
||||
|
||||
location / {
|
||||
proxy_pass http://gitea;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server (uncomment when SSL certificates are ready)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
#
|
||||
# location / {
|
||||
# root /usr/share/nginx/html;
|
||||
# try_files $uri $uri/ /index.html;
|
||||
# }
|
||||
#
|
||||
# location /api/ {
|
||||
# proxy_pass http://nanaxe_api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
#
|
||||
# location /git/ {
|
||||
# proxy_pass http://gitea/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe Server Deployment Script
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
BACKUP_DIR="./backups"
|
||||
LOG_FILE="./logs/deploy-$(date +%Y%m%d-%H%M%S).log"
|
||||
|
||||
# ==========================================
|
||||
# Helper Functions
|
||||
# ==========================================
|
||||
|
||||
log() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Pre-deployment Checks
|
||||
# ==========================================
|
||||
|
||||
check_prerequisites() {
|
||||
log "Checking prerequisites..."
|
||||
|
||||
# Check Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Docker Compose
|
||||
if ! docker compose version &> /dev/null; then
|
||||
error "Docker Compose is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check .env file
|
||||
if [ ! -f .env ]; then
|
||||
error ".env file not found. Please create it from .env.example"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check compose file
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
error "Compose file $COMPOSE_FILE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Prerequisites check passed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Backup
|
||||
# ==========================================
|
||||
|
||||
create_backup() {
|
||||
log "Creating backup..."
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Backup database
|
||||
if docker compose ps postgres | grep -q "running"; then
|
||||
log "Backing up PostgreSQL database..."
|
||||
docker compose exec -T postgres pg_dump -U gitea gitea > "$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql" 2>/dev/null || warning "Database backup failed"
|
||||
fi
|
||||
|
||||
# Backup mindmaps
|
||||
if [ -d "mindmap-data" ]; then
|
||||
log "Backing up mindmaps..."
|
||||
tar -czf "$BACKUP_DIR/mindmaps-$(date +%Y%m%d-%H%M%S).tar.gz" mindmap-data/ 2>/dev/null || warning "Mindmaps backup failed"
|
||||
fi
|
||||
|
||||
success "Backup completed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Deployment
|
||||
# ==========================================
|
||||
|
||||
deploy() {
|
||||
log "Starting deployment..."
|
||||
|
||||
# Pull latest images
|
||||
log "Pulling latest images..."
|
||||
docker compose -f "$COMPOSE_FILE" pull
|
||||
|
||||
# Deploy with zero downtime
|
||||
log "Deploying services..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||
|
||||
success "Deployment completed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Health Checks
|
||||
# ==========================================
|
||||
|
||||
health_check() {
|
||||
log "Running health checks..."
|
||||
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
# Wait for services to start
|
||||
sleep 10
|
||||
|
||||
# Check API
|
||||
log "Checking API health..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
||||
success "API is healthy"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
error "API health check failed after $max_attempts attempts"
|
||||
show_logs
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Attempt $attempt/$max_attempts: API not ready yet..."
|
||||
sleep 5
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
# Check Gitea
|
||||
log "Checking Gitea health..."
|
||||
attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if curl -sf http://localhost:3000/api/healthz > /dev/null 2>&1; then
|
||||
success "Gitea is healthy"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
warning "Gitea health check failed (may still be initializing)"
|
||||
break
|
||||
fi
|
||||
|
||||
log "Attempt $attempt/$max_attempts: Gitea not ready yet..."
|
||||
sleep 5
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
success "All health checks passed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Cleanup
|
||||
# ==========================================
|
||||
|
||||
cleanup() {
|
||||
log "Cleaning up..."
|
||||
|
||||
# Remove old backups (keep last 7)
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | tail -n +8 | xargs -r rm
|
||||
ls -t "$BACKUP_DIR"/*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm
|
||||
fi
|
||||
|
||||
# Clean Docker
|
||||
docker system prune -f
|
||||
docker volume prune -f
|
||||
|
||||
success "Cleanup completed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Rollback
|
||||
# ==========================================
|
||||
|
||||
rollback() {
|
||||
error "Deployment failed! Rolling back..."
|
||||
|
||||
# Get previous image
|
||||
local previous_image=$(docker images ghcr.io/*/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1)
|
||||
|
||||
if [ -n "$previous_image" ]; then
|
||||
log "Reverting to: $previous_image"
|
||||
docker compose -f "$COMPOSE_FILE" stop api
|
||||
docker compose -f "$COMPOSE_FILE" rm -f api
|
||||
docker run -d --name nanaxe-api-rollback \
|
||||
--network nanaxe-network \
|
||||
-v mindmap-data:/app/mindmaps \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=8080 \
|
||||
-p 8080:8080 \
|
||||
"$previous_image"
|
||||
success "Rollback completed"
|
||||
else
|
||||
error "No previous image found for rollback"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Show Logs
|
||||
# ==========================================
|
||||
|
||||
show_logs() {
|
||||
log "Showing recent logs..."
|
||||
docker compose -f "$COMPOSE_FILE" logs --tail=50
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Main
|
||||
# ==========================================
|
||||
|
||||
main() {
|
||||
log "======================================"
|
||||
log "Nanaxe Server Deployment"
|
||||
log "======================================"
|
||||
|
||||
# Create logs directory
|
||||
mkdir -p logs
|
||||
|
||||
# Run deployment steps
|
||||
check_prerequisites
|
||||
create_backup
|
||||
|
||||
# Deploy with error handling
|
||||
if deploy; then
|
||||
if health_check; then
|
||||
cleanup
|
||||
success "======================================"
|
||||
success "Deployment completed successfully!"
|
||||
success "======================================"
|
||||
log "API: http://localhost:8080"
|
||||
log "Gitea: http://localhost:3000"
|
||||
else
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# VPS Setup Script for Nanaxe Server
|
||||
# Run this on your fresh VPS
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[SETUP]${NC} $1"; }
|
||||
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# ==========================================
|
||||
# Configuration
|
||||
# ==========================================
|
||||
DEPLOY_DIR="/opt/nanaxe-server"
|
||||
REPO_URL="" # Will be set interactively
|
||||
DOMAIN="" # Will be set interactively
|
||||
|
||||
# ==========================================
|
||||
# System Update
|
||||
# ==========================================
|
||||
update_system() {
|
||||
log "Updating system packages..."
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl wget git nano htop ufw fail2ban
|
||||
success "System updated"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Install Docker
|
||||
# ==========================================
|
||||
install_docker() {
|
||||
log "Installing Docker..."
|
||||
|
||||
if command -v docker &> /dev/null; then
|
||||
warning "Docker already installed, skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sh get-docker.sh
|
||||
rm get-docker.sh
|
||||
|
||||
usermod -aG docker $USER
|
||||
|
||||
success "Docker installed"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Setup Firewall
|
||||
# ==========================================
|
||||
setup_firewall() {
|
||||
log "Configuring firewall..."
|
||||
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
|
||||
ufw allow ssh
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw allow 3000/tcp # Gitea
|
||||
ufw allow 8080/tcp # API
|
||||
ufw allow 222/tcp # Gitea SSH
|
||||
|
||||
ufw --force enable
|
||||
|
||||
success "Firewall configured"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Setup Fail2Ban
|
||||
# ==========================================
|
||||
setup_fail2ban() {
|
||||
log "Configuring Fail2Ban..."
|
||||
|
||||
cat > /etc/fail2ban/jail.local << 'EOF'
|
||||
[DEFAULT]
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
maxretry = 3
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
filter = sshd
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 3
|
||||
EOF
|
||||
|
||||
systemctl restart fail2ban
|
||||
success "Fail2Ban configured"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Setup Deployment Directory
|
||||
# ==========================================
|
||||
setup_deploy_dir() {
|
||||
log "Setting up deployment directory..."
|
||||
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
# Clone repository
|
||||
if [ -n "$REPO_URL" ]; then
|
||||
git clone "$REPO_URL" .
|
||||
fi
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p backups logs
|
||||
|
||||
success "Deployment directory ready"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Setup SSL with Let's Encrypt
|
||||
# ==========================================
|
||||
setup_ssl() {
|
||||
log "Setting up SSL certificates..."
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
warning "No domain specified, skipping SSL setup"
|
||||
return
|
||||
fi
|
||||
|
||||
apt install -y certbot
|
||||
|
||||
# Obtain certificates
|
||||
certbot certonly --standalone -d "$DOMAIN" --agree-tos --non-interactive --email admin@$DOMAIN || {
|
||||
warning "SSL setup failed, continuing without SSL"
|
||||
return
|
||||
}
|
||||
|
||||
# Auto-renewal cron job
|
||||
(crontab -l 2>/dev/null; echo "0 12 * * * /usr/bin/certbot renew --quiet") | crontab -
|
||||
|
||||
success "SSL certificates configured"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Create Environment File
|
||||
# ==========================================
|
||||
setup_env() {
|
||||
log "Creating environment configuration..."
|
||||
|
||||
cd "$DEPLOY_DIR/server"
|
||||
|
||||
# Generate secrets
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 24)
|
||||
GITEA_SECRET_KEY=$(openssl rand -base64 32)
|
||||
WEBDAV_PASSWORD=$(openssl rand -base64 16)
|
||||
|
||||
cat > .env << EOF
|
||||
# Domain configuration
|
||||
GITEA_DOMAIN=${DOMAIN:-localhost}
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_TOKEN=
|
||||
GITEA_SECRET_KEY=$GITEA_SECRET_KEY
|
||||
|
||||
# Security
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
|
||||
# WebDAV
|
||||
WEBDAV_USER=admin
|
||||
WEBDAV_PASSWORD=$WEBDAV_PASSWORD
|
||||
|
||||
# Database
|
||||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
|
||||
# API Configuration
|
||||
API_PORT=8080
|
||||
EOF
|
||||
|
||||
success "Environment file created"
|
||||
warning "Please edit .env file and add your GITEA_TOKEN after first Gitea setup"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Create Systemd Service
|
||||
# ==========================================
|
||||
setup_systemd() {
|
||||
log "Creating systemd service..."
|
||||
|
||||
cat > /etc/systemd/system/nanaxe-server.service << EOF
|
||||
[Unit]
|
||||
Description=Nanaxe Server
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=$DEPLOY_DIR/server
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nanaxe-server.service
|
||||
|
||||
success "Systemd service created"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Interactive Setup
|
||||
# ==========================================
|
||||
interactive_setup() {
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Nanaxe Server VPS Setup"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
read -p "Enter your GitHub repository URL (or leave empty to skip clone): " REPO_URL
|
||||
read -p "Enter your domain name (or leave empty for local setup): " DOMAIN
|
||||
|
||||
echo ""
|
||||
log "Starting setup with:"
|
||||
log " Repository: ${REPO_URL:-None}"
|
||||
log " Domain: ${DOMAIN:-localhost}"
|
||||
echo ""
|
||||
read -p "Continue? (y/n): " confirm
|
||||
|
||||
if [ "$confirm" != "y" ]; then
|
||||
error "Setup cancelled"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Main
|
||||
# ==========================================
|
||||
main() {
|
||||
interactive_setup
|
||||
|
||||
log "Starting VPS setup..."
|
||||
|
||||
update_system
|
||||
install_docker
|
||||
setup_firewall
|
||||
setup_fail2ban
|
||||
setup_deploy_dir
|
||||
setup_ssl
|
||||
setup_env
|
||||
setup_systemd
|
||||
|
||||
echo ""
|
||||
success "======================================"
|
||||
success "VPS Setup Complete!"
|
||||
success "======================================"
|
||||
echo ""
|
||||
log "Next steps:"
|
||||
echo " 1. Edit $DEPLOY_DIR/server/.env with your settings"
|
||||
echo " 2. Run: cd $DEPLOY_DIR/server && docker compose -f docker-compose.prod.yml up -d"
|
||||
echo " 3. Open Gitea at http://${DOMAIN:-your-server-ip}:3000"
|
||||
echo " 4. Create admin account and API token"
|
||||
echo " 5. Add GITEA_TOKEN to .env file"
|
||||
echo " 6. Restart: systemctl restart nanaxe-server"
|
||||
echo ""
|
||||
log "Useful commands:"
|
||||
echo " - View logs: docker compose -f docker-compose.prod.yml logs -f"
|
||||
echo " - Restart: systemctl restart nanaxe-server"
|
||||
echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart nanaxe-server"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user