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:
2026-04-19 22:18:25 +05:00
parent 52aea9ac87
commit f1e0f6b5ef
56 changed files with 8892 additions and 397 deletions
+21
View File
@@ -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
+369
View File
@@ -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/)
+25
View File
@@ -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"]
+34
View File
@@ -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"
}
}
+64
View File
@@ -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'}`);
});
+34
View File
@@ -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 };
+33
View File
@@ -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 };
+97
View File
@@ -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;
+266
View File
@@ -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;
+268
View File
@@ -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;
+261
View File
@@ -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;
+251
View File
@@ -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
+116
View File
@@ -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:
+144
View File
@@ -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>
+124
View File
@@ -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;
# }
# }
}
+263
View File
@@ -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 "$@"
+281
View File
@@ -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 "$@"