feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Nanaxe Server Configuration
# XFlow Server Configuration
# Copy this file to .env and update the values
# Domain configuration
-5453
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "nanaxe-api-server",
"name": "xflow-api-server",
"version": "1.0.0",
"description": "API server for Nanaxe CRM mindmap synchronization",
"description": "API server for XFlow CRM",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
@@ -31,4 +31,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}
+3 -9
View File
@@ -5,8 +5,6 @@ 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');
@@ -37,8 +35,8 @@ app.use(morgan('combined'));
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
@@ -46,8 +44,6 @@ app.get('/health', (req, res) => {
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/mindmaps', mindmapRoutes);
app.use('/api/sync', syncRoutes);
// Error handling
app.use(errorHandler);
@@ -58,7 +54,5 @@ app.use((req, res) => {
});
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'}`);
console.log(`🚀 XFlow API Server running on port ${PORT}`);
});
-266
View File
@@ -1,266 +0,0 @@
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
@@ -1,268 +0,0 @@
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
@@ -1,261 +0,0 @@
/**
* Конвертер между 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;
+9 -56
View File
@@ -1,4 +1,4 @@
# Nanaxe Server + Consyscab Integration
# XFlow Server + Consyscab Integration
# Объединённая конфигурация для совместного развёртывания
services:
@@ -15,8 +15,6 @@ services:
volumes:
- pg_data:/var/lib/postgresql/data
- ./docker/postgres/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
# Nanaxe: инициализация базы Gitea
- ./nanaxe/postgres-init:/docker-entrypoint-initdb.d/nanaxe:ro
networks:
- consyscab-network
healthcheck:
@@ -47,7 +45,7 @@ services:
- consyscab-network
# ==========================================
# 3. Nginx (Consyscab + Nanaxe)
# 3. Nginx (Consyscab + XFlow)
# ==========================================
consyscab_nginx:
image: nginx:alpine
@@ -59,13 +57,12 @@ services:
- ./backend:/var/www/symfony
- ./moodle:/var/www/moodle
- ./docker/nginx/conf.d:/etc/nginx/conf.d
# Nanaxe: дополнительная конфигурация
- ./nanaxe/nginx:/etc/nginx/conf.d/nanaxe:ro
- ./nanaxe/html:/usr/share/nginx/html/nanaxe:ro
# XFlow: дополнительная конфигурация
- ./xflow/nginx:/etc/nginx/conf.d/xflow:ro
- ./xflow/html:/usr/share/nginx/html/xflow:ro
depends_on:
- consyscab_php
- nanaxe_gitea
- nanaxe_api
- xflow_gitea
networks:
- consyscab-network
@@ -124,11 +121,11 @@ services:
- consyscab-network
# ==========================================
# 8. Nanaxe Gitea (Git сервер)
# 8. XFlow Gitea (Git сервер)
# ==========================================
nanaxe_gitea:
xflow_gitea:
image: gitea/gitea:latest
container_name: nanaxe_gitea
container_name: xflow_gitea
environment:
- USER_UID=1000
- USER_GID=1000
@@ -162,49 +159,6 @@ services:
retries: 3
start_period: 40s
# ==========================================
# 9. Nanaxe API Server
# ==========================================
nanaxe_api:
build:
context: ./nanaxe/api
dockerfile: Dockerfile
container_name: nanaxe_api
environment:
- NODE_ENV=production
- PORT=8080
- GITEA_URL=http://nanaxe_gitea:3000
- GITEA_TOKEN=${GITEA_TOKEN}
- JWT_SECRET=${NANAXE_JWT_SECRET}
- MINDMAP_STORAGE_PATH=/app/mindmaps
volumes:
- mindmap_data:/app/mindmaps
- ./nanaxe/logs:/app/logs
depends_on:
- nanaxe_gitea
- consyscab_db
networks:
- consyscab-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
# ==========================================
# 10. Nanaxe WebDAV (опционально)
# ==========================================
nanaxe_webdav:
image: bytemark/webdav
container_name: nanaxe_webdav
environment:
- AUTH_TYPE=Basic
- USERNAME=${WEBDAV_USER:-admin}
- PASSWORD=${WEBDAV_PASSWORD}
volumes:
- mindmap_data:/var/lib/dav
networks:
- consyscab-network
networks:
consyscab-network:
@@ -213,4 +167,3 @@ networks:
volumes:
pg_data:
gitea_data:
mindmap_data:
+1 -1
View File
@@ -15,7 +15,7 @@ services:
ports:
- "5432:5432"
nanaxe-api:
xflow-api:
ports:
- "8080:8080"
environment:
+13 -66
View File
@@ -9,10 +9,10 @@ services:
# ==========================================
gitea:
image: gitea/gitea:latest-rootless
container_name: nanaxe-gitea
container_name: xflow-gitea
restart: unless-stopped
networks:
- nanaxe-network
- xflow-network
volumes:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
@@ -56,10 +56,10 @@ services:
# ==========================================
postgres:
image: postgres:16-alpine
container_name: nanaxe-postgres
container_name: xflow-postgres
restart: unless-stopped
networks:
- nanaxe-network
- xflow-network
volumes:
- postgres-data:/var/lib/postgresql/data
- ./backups:/backups
@@ -82,65 +82,15 @@ services:
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
container_name: xflow-nginx
restart: unless-stopped
networks:
- nanaxe-network
- xflow-network
ports:
- "80:80"
- "443:443"
@@ -162,17 +112,16 @@ services:
memory: 128M
depends_on:
- gitea
- nanaxe-api
# ==========================================
# Certbot - SSL Certificates
# ==========================================
certbot:
image: certbot/certbot
container_name: nanaxe-certbot
container_name: xflow-certbot
restart: "no"
networks:
- nanaxe-network
- xflow-network
volumes:
- certbot-data:/etc/letsencrypt
- certbot-www:/var/www/certbot
@@ -185,10 +134,10 @@ services:
# ==========================================
watchtower:
image: containrrr/watchtower
container_name: nanaxe-watchtower
container_name: xflow-watchtower
restart: unless-stopped
networks:
- nanaxe-network
- xflow-network
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
@@ -207,10 +156,10 @@ services:
# ==========================================
redis:
image: redis:7-alpine
container_name: nanaxe-redis
container_name: xflow-redis
restart: unless-stopped
networks:
- nanaxe-network
- xflow-network
volumes:
- redis-data:/data
command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
@@ -228,7 +177,7 @@ services:
- cache
networks:
nanaxe-network:
xflow-network:
driver: bridge
ipam:
config:
@@ -239,8 +188,6 @@ volumes:
driver: local
postgres-data:
driver: local
mindmap-data:
driver: local
api-logs:
driver: local
certbot-data:
+72 -96
View File
@@ -1,121 +1,97 @@
version: '3.8'
# Nanaxe Server - Mindmap Synchronization
# Run alongside other Docker projects by changing ports
# Инфраструктура AVTEX
# FastPanel проксирует порты напрямую — nginx контейнер не нужен
# Один postgres для всех проектов организации
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=git.a-crm.controlsystem.kz
- GITEA__server__ROOT_URL=http://git.a-crm.controlsystem.kz/
- GITEA__server__SSH_DOMAIN=git.a-crm.controlsystem.kz
- GITEA__server__SSH_PORT=2221
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__security__INSTALL_LOCK=true
- GITEA__service__DISABLE_REGISTRATION=false
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
# PostgreSQL — общий для всех проектов
postgres:
image: postgres:16-alpine
container_name: postgres
restart: always
networks:
- nanaxe-network
- infra-network
ports:
- "5432:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
volumes:
- postgres-data:/var/lib/postgresql/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Gitea — Git сервер организации AVTEX
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
networks:
- infra-network
ports:
- "3001:3000"
- "2221:22"
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_DB_PASSWORD:-gitea_password}
GITEA__server__DOMAIN: git.a-crm.controlsystem.kz
GITEA__server__ROOT_URL: http://git.a-crm.controlsystem.kz/
GITEA__server__SSH_DOMAIN: git.a-crm.controlsystem.kz
GITEA__server__SSH_PORT: 2221
GITEA__server__SSH_LISTEN_PORT: 22
GITEA__security__INSTALL_LOCK: "true"
GITEA__service__DISABLE_REGISTRATION: "false"
volumes:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3001:3000" # Changed from 3000 to avoid conflicts
- "2221:22" # Changed from 222 to avoid conflicts
depends_on:
- postgres
# PostgreSQL для Gitea
postgres:
image: postgres:16-alpine
container_name: nanaxe-postgres
ports:
- "5433:5432" # Expose on different port
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea_password
- POSTGRES_DB=gitea
# XFlow — Mind Map приложение
xflow:
image: node:20-alpine
container_name: xflow
restart: always
working_dir: /app
command: node .output/server/index.mjs
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
- infra-network
ports:
- "8082:8080" # Changed from 8080 to avoid conflicts
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
NUXT_JWT_SECRET: ${NUXT_JWT_SECRET}
GITEA_URL: http://gitea:3000
GITEA_TOKEN: ${GITEA_TOKEN}
GITEA_ORG: ${GITEA_ORG:-AVTEX}
GITEA_MINDMAP_REPO: ${GITEA_MINDMAP_REPO:-MMAVTEX}
GITEA_PROJECT_REPO: ${GITEA_PROJECT_REPO:-XFlow}
DATABASE_URL: postgresql://xflow:${XFLOW_DB_PASSWORD:-xflow_pass}@postgres:5432/xflow
NUXT_PUBLIC_FIREBASE_API_KEY: ${NUXT_PUBLIC_FIREBASE_API_KEY}
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN}
NUXT_PUBLIC_FIREBASE_PROJECT_ID: ${NUXT_PUBLIC_FIREBASE_PROJECT_ID}
NUXT_PUBLIC_FIREBASE_APP_ID: ${NUXT_PUBLIC_FIREBASE_APP_ID}
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID}
volumes:
- /opt/xflow/.output:/app/.output:ro
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:
- "8083:80" # Changed from 80 to avoid conflicts
- "8444:443" # Changed from 443 to avoid conflicts
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:
- "8084:80" # Changed from 8081 to avoid conflicts
networks:
nanaxe-network:
infra-network:
driver: bridge
volumes:
gitea-data:
postgres-data:
mindmap-data:
gitea-data:
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
apps: [{
name: 'nanaxe-api',
name: 'xflow-api',
script: './api/src/index.js',
instances: 1,
exec_mode: 'fork',
+38 -28
View File
@@ -1,16 +1,17 @@
<!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>
<title>XFlow 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%);
@@ -20,24 +21,24 @@
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);
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));
@@ -45,7 +46,7 @@
max-width: 800px;
margin: 0 auto;
}
.service-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
@@ -54,22 +55,22 @@
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);
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;
@@ -80,12 +81,12 @@
font-weight: 600;
transition: all 0.3s;
}
.service-card a:hover {
background: #f0f0f0;
transform: scale(1.05);
}
.status {
margin-top: 2rem;
padding: 1rem;
@@ -93,7 +94,7 @@
border-radius: 8px;
display: inline-block;
}
.status-indicator {
display: inline-block;
width: 10px;
@@ -103,42 +104,51 @@
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
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>
<h1>🚀 XFlow Server</h1>
<p>XFlow CRM Server</p>
<div class="services">
<div class="service-card">
<h3>📚 Git Repository</h3>
<p>Access your mindmaps via Git</p>
<p>Self-hosted Git server</p>
<a href="/git/" target="_blank">Open Gitea</a>
</div>
<div class="service-card">
<h3>🔌 API</h3>
<p>REST API for mindmap operations</p>
<p>REST API for CRM operations</p>
<a href="/api/health" target="_blank">Check API</a>
</div>
<div class="service-card">
<h3>📱 WebDAV</h3>
<p>Direct file access</p>
<a href="http://a-crm.controlsystem.kz:8084" target="_blank">Open WebDAV</a>
</div>
</div>
<div class="status">
<span class="status-indicator"></span>
<span>Server is running</span>
</div>
</div>
</body>
</html>
</html>
+2 -2
View File
@@ -42,7 +42,7 @@ http {
# API proxy
location /api/ {
set $api_backend nanaxe-api:8080;
set $api_backend xflow-api:8080;
proxy_pass http://$api_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -88,7 +88,7 @@ http {
# }
#
# location /api/ {
# proxy_pass http://nanaxe_api/;
# proxy_pass http://xflow_api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "nanaxe-api-server",
"name": "xflow-api-server",
"version": "1.0.0",
"description": "API server for Nanaxe CRM mindmap synchronization - FastPanel compatible",
"description": "API server for XFlow CRM - FastPanel compatible",
"main": "api/src/index.js",
"scripts": {
"start": "node api/src/index.js",
@@ -9,7 +9,7 @@
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop ecosystem.config.js",
"pm2:restart": "pm2 restart ecosystem.config.js",
"pm2:logs": "pm2 logs nanaxe-api",
"pm2:logs": "pm2 logs xflow-api",
"install:deps": "npm ci --production"
},
"dependencies": {
@@ -35,4 +35,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}
+5 -12
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# ==========================================
# Nanaxe Server Deployment Script
# XFlow Server Deployment Script
# ==========================================
set -e
@@ -87,12 +87,6 @@ create_backup() {
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"
}
@@ -196,15 +190,14 @@ 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)
local previous_image=$(docker images ghcr.io/*/xflow-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 \
docker run -d --name xflow-api-rollback \
--network xflow-network \
-e NODE_ENV=production \
-e PORT=8080 \
-p 8080:8080 \
@@ -230,7 +223,7 @@ show_logs() {
main() {
log "======================================"
log "Nanaxe Server Deployment"
log "XFlow Server Deployment"
log "======================================"
# Create logs directory
+9 -9
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# ==========================================
# VPS Setup Script for Nanaxe Server
# VPS Setup Script for XFlow Server
# Run this on your fresh VPS
# ==========================================
@@ -21,7 +21,7 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; }
# ==========================================
# Configuration
# ==========================================
DEPLOY_DIR="/opt/nanaxe-server"
DEPLOY_DIR="/opt/xflow-server"
REPO_URL="" # Will be set interactively
DOMAIN="" # Will be set interactively
@@ -191,9 +191,9 @@ EOF
setup_systemd() {
log "Creating systemd service..."
cat > /etc/systemd/system/nanaxe-server.service << EOF
cat > /etc/systemd/system/xflow-server.service << EOF
[Unit]
Description=Nanaxe Server
Description=XFlow Server
Requires=docker.service
After=docker.service
@@ -210,7 +210,7 @@ WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable nanaxe-server.service
systemctl enable xflow-server.service
success "Systemd service created"
}
@@ -221,7 +221,7 @@ EOF
interactive_setup() {
echo ""
echo "======================================"
echo "Nanaxe Server VPS Setup"
echo "XFlow Server VPS Setup"
echo "======================================"
echo ""
@@ -269,12 +269,12 @@ main() {
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 " 6. Restart: systemctl restart xflow-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 " - Restart: systemctl restart xflow-server"
echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart xflow-server"
echo ""
}
@@ -1,4 +1,4 @@
# Nanaxe Server Nginx Configuration
# XFlow Server Nginx Configuration
# Добавьте этот файл в ./docker/nginx/conf.d/ вашего друга
# Resolver для Docker DNS
@@ -14,7 +14,7 @@ server {
client_max_body_size 512M;
location / {
set $gitea_backend nanaxe_gitea:3000;
set $gitea_backend xflow_gitea:3000;
proxy_pass http://$gitea_backend;
proxy_http_version 1.1;
@@ -34,14 +34,14 @@ server {
}
# ==========================================
# Nanaxe API
# XFlow API
# ==========================================
server {
listen 80;
server_name api.* ~^api\..+$;
location / {
set $api_backend nanaxe_api:8080;
set $api_backend xflow_api:8080;
proxy_pass http://$api_backend;
proxy_http_version 1.1;
@@ -66,19 +66,19 @@ server {
}
# ==========================================
# Nanaxe Dashboard (опционально)
# XFlow Dashboard (опционально)
# ==========================================
server {
listen 80;
server_name nanaxe.* ~^nanaxe\..+$;
server_name xflow.* ~^xflow\..+$;
location / {
root /usr/share/nginx/html/nanaxe;
root /usr/share/nginx/html/xflow;
try_files $uri $uri/ /index.html;
}
location /api/ {
set $api_backend nanaxe_api:8080;
set $api_backend xflow_api:8080;
proxy_pass http://$api_backend/;
proxy_http_version 1.1;
@@ -97,7 +97,7 @@ server {
server_name webdav.* ~^webdav\..+$;
location / {
set $webdav_backend nanaxe_webdav:80;
set $webdav_backend xflow_webdav:80;
proxy_pass http://$webdav_backend;
proxy_http_version 1.1;