Add Gitea integration + ESLint setup
Phase 3: Gitea Integration - Migration 012: app_settings table + gitea_commit_sha on endpoint_versions - SettingsService: encrypted token storage (AES-256-GCM from JWT_SECRET) - GiteaService: full REST API client — repo management, file CRUD, branches, commit history, diff/compare, pull requests, sync all - giteaController + routes: 15 API endpoints for settings, branches, commits, PRs, endpoint info, sync - VersionService hooks: auto-sync to Gitea on publish/draft (non-blocking) - Frontend: Gitea tab in Settings (connection, sync status, branch mgmt), Gitea panel in EndpointEditor (file link, last commit SHA) - docker-compose.gitea.yml: optional companion Gitea container - Cache fix: index.html served with no-cache for instant deploys ESLint Setup - Backend: eslint 8 + @typescript-eslint configured - Frontend: eslint 8 + @typescript-eslint + react-hooks + react-refresh - Fixed 15 lint issues: unused imports, require statements, escapes, Function type, empty catch blocks - Added npm run lint / lint:fix scripts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
backend/.eslintrc.cjs
Normal file
21
backend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
};
|
||||
1495
backend/package-lock.json
generated
1495
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint": "^8.57.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"nodemon": "^3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import { config } from './environment';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const getApiKeys = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mainPool } from '../config/database';
|
||||
import { config } from '../config/environment';
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export const testDatabaseConnection = async (req: AuthRequest, res: Response) =>
|
||||
const dbType = dbResult.rows[0].type;
|
||||
|
||||
if (dbType === 'aql') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||
const result = await aqlExecutor.testConnection(id, env);
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response)
|
||||
return res.status(500).json({ error: 'AQL configuration is incomplete' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||
result = await aqlExecutor.executeAqlQuery(endpoint.database_id, {
|
||||
method: aqlMethod,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ExportedEndpoint, ExportedScriptQuery, ScriptExecutionError, Environment } from '../types';
|
||||
import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto';
|
||||
import { versionService } from '../services/VersionService';
|
||||
@@ -325,6 +324,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { sqlExecutor } = require('../services/SqlExecutor');
|
||||
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || [], environment);
|
||||
|
||||
@@ -352,6 +352,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { scriptExecutor } = require('../services/ScriptExecutor');
|
||||
const scriptResult = await scriptExecutor.execute(script_language, script_code, {
|
||||
databaseId: database_id,
|
||||
@@ -383,6 +384,7 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { aqlExecutor } = require('../services/AqlExecutor');
|
||||
const result = await aqlExecutor.executeAqlQuery(database_id, {
|
||||
method: aql_method,
|
||||
@@ -512,8 +514,8 @@ export const exportEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
const encrypted = encryptEndpointData(exportData);
|
||||
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[\/\\:*?"<>|]/g, '_')) + '.kabe';
|
||||
const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const encodedFileName = encodeURIComponent(endpoint.name.replace(/[/\\:*?"<>|]/g, '_')) + '.kabe';
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"; filename*=UTF-8''${encodedFileName}`);
|
||||
res.send(encrypted);
|
||||
|
||||
155
backend/src/controllers/giteaController.ts
Normal file
155
backend/src/controllers/giteaController.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { settingsService } from '../services/SettingsService';
|
||||
import { giteaService } from '../services/GiteaService';
|
||||
|
||||
export const getSettings = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const settings = await settingsService.getGiteaSettings();
|
||||
if (!settings) return res.json({ enabled: false, url: '', token: '', owner: '', repo: '', prod_branch: 'main' });
|
||||
// Mask token
|
||||
const masked = settings.token
|
||||
? '****' + settings.token.slice(-4)
|
||||
: '';
|
||||
res.json({ ...settings, token: masked });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSettings = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { enabled, url, token, owner, repo, prod_branch } = req.body;
|
||||
|
||||
// If token is masked (starts with ****), keep existing
|
||||
let actualToken = token;
|
||||
if (token?.startsWith('****')) {
|
||||
const existing = await settingsService.getGiteaSettings();
|
||||
actualToken = existing?.token || '';
|
||||
}
|
||||
|
||||
await settingsService.setGiteaSettings({
|
||||
enabled: enabled ?? false,
|
||||
url: url || '',
|
||||
token: actualToken || '',
|
||||
owner: owner || '',
|
||||
repo: repo || '',
|
||||
prod_branch: prod_branch || 'main',
|
||||
}, req.user!.id);
|
||||
|
||||
giteaService.invalidateCache();
|
||||
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const testGiteaConnection = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await giteaService.testConnection();
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const syncAll = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await giteaService.initializeRepo();
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getSyncStatus = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const status = await giteaService.getSyncStatus();
|
||||
res.json(status);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const listBranches = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const branches = await giteaService.listBranches();
|
||||
res.json(branches);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createBranch = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, from } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Branch name required' });
|
||||
await giteaService.createBranch(name, from);
|
||||
res.json({ message: `Branch ${name} created` });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBranch = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
await giteaService.deleteBranch(name);
|
||||
res.json({ message: `Branch ${name} deleted` });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const compareBranches = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { base, head } = req.query as { base: string; head: string };
|
||||
if (!base || !head) return res.status(400).json({ error: 'base and head required' });
|
||||
const diff = await giteaService.compareBranches(base, head);
|
||||
res.json(diff);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getCommitHistory = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { path, branch } = req.query as { path?: string; branch?: string };
|
||||
const commits = await giteaService.getCommitHistory(path, branch);
|
||||
res.json(commits);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getEndpointInfo = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const info = await giteaService.getEndpointGiteaInfo(id);
|
||||
res.json(info || { enabled: false });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createPR = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { title, head, base, body } = req.body;
|
||||
if (!title || !head) return res.status(400).json({ error: 'title and head branch required' });
|
||||
const pr = await giteaService.createPR(title, head, base, body);
|
||||
res.json(pr);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const mergePR = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { index } = req.params;
|
||||
await giteaService.mergePR(parseInt(index));
|
||||
res.json({ message: 'PR merged' });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
10
backend/src/migrations/012_add_app_settings.sql
Normal file
10
backend/src/migrations/012_add_app_settings.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Generic application settings (key-value store)
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Track Gitea commit SHA in endpoint versions
|
||||
ALTER TABLE endpoint_versions ADD COLUMN IF NOT EXISTS gitea_commit_sha VARCHAR(64);
|
||||
48
backend/src/routes/gitea.ts
Normal file
48
backend/src/routes/gitea.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
testGiteaConnection,
|
||||
syncAll,
|
||||
getSyncStatus,
|
||||
listBranches,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
compareBranches,
|
||||
getCommitHistory,
|
||||
getEndpointInfo,
|
||||
createPR,
|
||||
mergePR,
|
||||
} from '../controllers/giteaController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Settings
|
||||
router.get('/settings', getSettings);
|
||||
router.put('/settings', updateSettings);
|
||||
router.post('/test', testGiteaConnection);
|
||||
|
||||
// Sync
|
||||
router.post('/sync-all', syncAll);
|
||||
router.get('/sync-status', getSyncStatus);
|
||||
|
||||
// Branches
|
||||
router.get('/branches', listBranches);
|
||||
router.post('/branches', createBranch);
|
||||
router.delete('/branches/:name', deleteBranch);
|
||||
|
||||
// Diff & History
|
||||
router.get('/compare', compareBranches);
|
||||
router.get('/commits', getCommitHistory);
|
||||
|
||||
// Endpoint-specific
|
||||
router.get('/endpoints/:id/info', getEndpointInfo);
|
||||
|
||||
// Pull Requests
|
||||
router.post('/pulls', createPR);
|
||||
router.post('/pulls/:index/merge', mergePR);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
// import helmet from 'helmet';
|
||||
// import rateLimit from 'express-rate-limit';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import path from 'path';
|
||||
@@ -21,6 +21,7 @@ import logsRoutes from './routes/logs';
|
||||
import sqlInterfaceRoutes from './routes/sqlInterface';
|
||||
import dynamicRoutes from './routes/dynamic';
|
||||
import syncRoutes from './routes/sync';
|
||||
import giteaRoutes from './routes/gitea';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
@@ -95,6 +96,7 @@ app.use('/api/users', userRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
app.use('/api/workbench', sqlInterfaceRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
app.use('/api/gitea', giteaRoutes);
|
||||
|
||||
// Dynamic API routes (user-created endpoints)
|
||||
app.use('/api/v1', dynamicRoutes);
|
||||
@@ -107,8 +109,13 @@ if (config.nodeEnv === 'production') {
|
||||
app.use(express.static(frontendPath, {
|
||||
maxAge: '1d',
|
||||
etag: true,
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
setHeaders: (res, filePath) => {
|
||||
// index.html must not be cached — it references hashed assets
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -116,7 +123,7 @@ if (config.nodeEnv === 'production') {
|
||||
app.get('*', (req: Request, res: Response) => {
|
||||
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-docs') && req.path !== '/health') {
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
console.log(`📄 Serving index.html for route: ${req.path}`);
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
res.status(404).json({ error: 'API route not found' });
|
||||
|
||||
459
backend/src/services/GiteaService.ts
Normal file
459
backend/src/services/GiteaService.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { settingsService } from './SettingsService';
|
||||
import { mainPool } from '../config/database';
|
||||
import { GiteaSettings, GiteaBranch, GiteaCommit, GiteaDiff } from '../types';
|
||||
|
||||
class GiteaService {
|
||||
private cachedConfig: GiteaSettings | null = null;
|
||||
private cacheTime = 0;
|
||||
private readonly CACHE_TTL = 60000;
|
||||
|
||||
invalidateCache() {
|
||||
this.cachedConfig = null;
|
||||
this.cacheTime = 0;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<GiteaSettings | null> {
|
||||
if (this.cachedConfig && Date.now() - this.cacheTime < this.CACHE_TTL) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
this.cachedConfig = await settingsService.getGiteaSettings();
|
||||
this.cacheTime = Date.now();
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
async isEnabled(): Promise<boolean> {
|
||||
const cfg = await this.getConfig();
|
||||
return !!(cfg?.enabled && cfg.url && cfg.token && cfg.owner && cfg.repo);
|
||||
}
|
||||
|
||||
private async api(method: string, path: string, body?: any): Promise<any> {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) throw new Error('Gitea not configured');
|
||||
|
||||
const url = `${cfg.url.replace(/\/+$/, '')}/api/v1${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `token ${cfg.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 204) return null;
|
||||
if (res.status === 404) return null;
|
||||
|
||||
const data: any = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data?.message || `Gitea API error ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Repo management ---
|
||||
|
||||
async ensureRepo(): Promise<void> {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) throw new Error('Gitea not configured');
|
||||
|
||||
const existing = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}`);
|
||||
if (existing) return;
|
||||
|
||||
// Try creating under user first, then org
|
||||
try {
|
||||
await this.api('POST', '/user/repos', {
|
||||
name: cfg.repo,
|
||||
description: 'KIS API Builder Endpoints',
|
||||
private: true,
|
||||
auto_init: true,
|
||||
default_branch: cfg.prod_branch || 'main',
|
||||
});
|
||||
} catch {
|
||||
await this.api('POST', `/orgs/${cfg.owner}/repos`, {
|
||||
name: cfg.repo,
|
||||
description: 'KIS API Builder Endpoints',
|
||||
private: true,
|
||||
auto_init: true,
|
||||
default_branch: cfg.prod_branch || 'main',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return { success: false, message: 'Gitea not configured' };
|
||||
|
||||
const user = await this.api('GET', '/user');
|
||||
if (!user) return { success: false, message: 'Failed to authenticate' };
|
||||
|
||||
await this.ensureRepo();
|
||||
return { success: true, message: `Connected as ${user.login}, repo ready` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- File operations ---
|
||||
|
||||
private async getFileSha(filePath: string, branch: string): Promise<string | null> {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return null;
|
||||
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}?ref=${encodeURIComponent(branch)}`);
|
||||
return data?.sha || null;
|
||||
}
|
||||
|
||||
private async commitFile(filePath: string, content: string, message: string, branch: string): Promise<string | null> {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return null;
|
||||
|
||||
const encoded = Buffer.from(content, 'utf-8').toString('base64');
|
||||
const sha = await this.getFileSha(filePath, branch);
|
||||
|
||||
const body: any = {
|
||||
content: encoded,
|
||||
message,
|
||||
branch,
|
||||
};
|
||||
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
const res = await this.api('PUT', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, body);
|
||||
return res?.content?.sha || null;
|
||||
} else {
|
||||
const res = await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, body);
|
||||
return res?.content?.sha || null;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFile(filePath: string, branch: string, message: string): Promise<void> {
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return;
|
||||
|
||||
const sha = await this.getFileSha(filePath, branch);
|
||||
if (!sha) return;
|
||||
|
||||
await this.api('DELETE', `/repos/${cfg.owner}/${cfg.repo}/contents/${filePath}`, {
|
||||
sha,
|
||||
message,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
async commitEndpointFiles(
|
||||
endpoint: any,
|
||||
branch: string,
|
||||
message: string
|
||||
): Promise<string | null> {
|
||||
if (!await this.isEnabled()) return null;
|
||||
|
||||
const folderName = endpoint.folder_name ? this.sanitize(endpoint.folder_name) : '_root';
|
||||
const epName = this.sanitize(endpoint.name);
|
||||
const basePath = `endpoints/${folderName}/${epName}`;
|
||||
|
||||
// endpoint.json
|
||||
const meta = {
|
||||
name: endpoint.name,
|
||||
description: endpoint.description || '',
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
execution_type: endpoint.execution_type || 'sql',
|
||||
database_name: endpoint.database_name || null,
|
||||
parameters: endpoint.parameters || [],
|
||||
is_public: endpoint.is_public || false,
|
||||
enable_logging: endpoint.enable_logging || false,
|
||||
detailed_response: endpoint.detailed_response || false,
|
||||
response_schema: endpoint.response_schema || null,
|
||||
};
|
||||
|
||||
let lastSha = await this.commitFile(
|
||||
`${basePath}/endpoint.json`,
|
||||
JSON.stringify(meta, null, 2),
|
||||
message,
|
||||
branch
|
||||
);
|
||||
|
||||
// SQL query
|
||||
if (endpoint.execution_type === 'sql' && endpoint.sql_query) {
|
||||
lastSha = await this.commitFile(`${basePath}/query.sql`, endpoint.sql_query, message, branch);
|
||||
}
|
||||
|
||||
// Script
|
||||
if (endpoint.execution_type === 'script' && endpoint.script_code) {
|
||||
const ext = endpoint.script_language === 'python' ? 'py' : 'js';
|
||||
lastSha = await this.commitFile(`${basePath}/main.${ext}`, endpoint.script_code, message, branch);
|
||||
}
|
||||
|
||||
// AQL
|
||||
if (endpoint.execution_type === 'aql') {
|
||||
const method = endpoint.aql_method || 'GET';
|
||||
const ep = endpoint.aql_endpoint || '';
|
||||
const body = endpoint.aql_body || '';
|
||||
let url = ep;
|
||||
const qp = endpoint.aql_query_params || {};
|
||||
const params = Object.entries(qp).map(([k, v]) => `${k}=${v}`).join('&');
|
||||
if (params) url += (url.includes('?') ? '&' : '?') + params;
|
||||
const httpContent = `${method} ${url}\nContent-Type: application/json\n${body ? '\n' + body + '\n' : ''}`;
|
||||
lastSha = await this.commitFile(`${basePath}/request.http`, httpContent, message, branch);
|
||||
}
|
||||
|
||||
// Script queries
|
||||
const scriptQueries = endpoint.script_queries || [];
|
||||
if (endpoint.execution_type === 'script' && scriptQueries.length > 0) {
|
||||
const queryIndex: any[] = [];
|
||||
for (const sq of scriptQueries) {
|
||||
const sqMeta: any = { name: sq.name, database_id: sq.database_id || null };
|
||||
if (sq.sql) {
|
||||
const fileName = `${this.sanitize(sq.name)}.sql`;
|
||||
await this.commitFile(`${basePath}/queries/${fileName}`, sq.sql, message, branch);
|
||||
sqMeta.file = fileName;
|
||||
}
|
||||
if (sq.aql_method) {
|
||||
const fileName = `${this.sanitize(sq.name)}.http`;
|
||||
const httpContent = `${sq.aql_method} ${sq.aql_endpoint || ''}\nContent-Type: application/json\n${sq.aql_body ? '\n' + sq.aql_body + '\n' : ''}`;
|
||||
await this.commitFile(`${basePath}/queries/${fileName}`, httpContent, message, branch);
|
||||
sqMeta.file = fileName;
|
||||
sqMeta.type = 'aql';
|
||||
}
|
||||
queryIndex.push(sqMeta);
|
||||
}
|
||||
await this.commitFile(`${basePath}/queries/_index.json`, JSON.stringify(queryIndex, null, 2), message, branch);
|
||||
}
|
||||
|
||||
return lastSha;
|
||||
}
|
||||
|
||||
async deleteEndpointFiles(endpointName: string, folderName: string | null, branch: string): Promise<void> {
|
||||
if (!await this.isEnabled()) return;
|
||||
|
||||
const folder = folderName ? this.sanitize(folderName) : '_root';
|
||||
const basePath = `endpoints/${folder}/${this.sanitize(endpointName)}`;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return;
|
||||
|
||||
// List files in the directory
|
||||
const contents = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${basePath}?ref=${encodeURIComponent(branch)}`);
|
||||
if (!Array.isArray(contents)) return;
|
||||
|
||||
for (const file of contents) {
|
||||
if (file.type === 'dir') {
|
||||
// Recurse into subdirectories (queries/)
|
||||
const subContents = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${file.path}?ref=${encodeURIComponent(branch)}`);
|
||||
if (Array.isArray(subContents)) {
|
||||
for (const subFile of subContents) {
|
||||
await this.deleteFile(subFile.path, branch, `Delete ${endpointName}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.deleteFile(file.path, branch, `Delete ${endpointName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Branch management ---
|
||||
|
||||
async listBranches(): Promise<GiteaBranch[]> {
|
||||
if (!await this.isEnabled()) return [];
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return [];
|
||||
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/branches`);
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map((b: any) => ({
|
||||
name: b.name,
|
||||
commit: { id: b.commit?.id || '', message: b.commit?.message || '', timestamp: b.commit?.timestamp || '' },
|
||||
}));
|
||||
}
|
||||
|
||||
async createBranch(name: string, from?: string): Promise<void> {
|
||||
if (!await this.isEnabled()) return;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return;
|
||||
await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/branches`, {
|
||||
new_branch_name: name,
|
||||
old_branch_name: from || cfg.prod_branch || 'main',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBranch(name: string): Promise<void> {
|
||||
if (!await this.isEnabled()) return;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return;
|
||||
await this.api('DELETE', `/repos/${cfg.owner}/${cfg.repo}/branches/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
|
||||
async getCommitHistory(filePath?: string, branch?: string): Promise<GiteaCommit[]> {
|
||||
if (!await this.isEnabled()) return [];
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return [];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (filePath) params.set('path', filePath);
|
||||
if (branch) params.set('sha', branch);
|
||||
params.set('limit', '50');
|
||||
|
||||
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/commits?${params.toString()}`);
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data.map((c: any) => ({
|
||||
sha: c.sha,
|
||||
message: c.commit?.message || '',
|
||||
author: {
|
||||
name: c.commit?.author?.name || '',
|
||||
email: c.commit?.author?.email || '',
|
||||
date: c.commit?.author?.date || '',
|
||||
},
|
||||
html_url: c.html_url || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Diff ---
|
||||
|
||||
async compareBranches(base: string, head: string): Promise<GiteaDiff | null> {
|
||||
if (!await this.isEnabled()) return null;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return null;
|
||||
|
||||
const data = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
total_commits: data.total_commits || 0,
|
||||
commits: (data.commits || []).map((c: any) => ({
|
||||
sha: c.sha,
|
||||
message: c.commit?.message || '',
|
||||
author: { name: c.commit?.author?.name || '', email: c.commit?.author?.email || '', date: c.commit?.author?.date || '' },
|
||||
html_url: c.html_url || '',
|
||||
})),
|
||||
files: (data.files || []).map((f: any) => ({
|
||||
filename: f.filename,
|
||||
status: f.status,
|
||||
additions: f.additions || 0,
|
||||
deletions: f.deletions || 0,
|
||||
patch: f.patch,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Pull Requests ---
|
||||
|
||||
async createPR(title: string, headBranch: string, baseBranch?: string, body?: string): Promise<any> {
|
||||
if (!await this.isEnabled()) return null;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return null;
|
||||
|
||||
return this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/pulls`, {
|
||||
title,
|
||||
head: headBranch,
|
||||
base: baseBranch || cfg.prod_branch || 'main',
|
||||
body: body || '',
|
||||
});
|
||||
}
|
||||
|
||||
async mergePR(prIndex: number, deleteBranch = true): Promise<void> {
|
||||
if (!await this.isEnabled()) return;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return;
|
||||
|
||||
await this.api('POST', `/repos/${cfg.owner}/${cfg.repo}/pulls/${prIndex}/merge`, {
|
||||
Do: 'merge',
|
||||
delete_branch_after_merge: deleteBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sync all endpoints ---
|
||||
|
||||
async initializeRepo(): Promise<{ synced: number; errors: string[] }> {
|
||||
if (!await this.isEnabled()) throw new Error('Gitea not enabled');
|
||||
|
||||
await this.ensureRepo();
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) throw new Error('Gitea not configured');
|
||||
|
||||
const result = await mainPool.query(
|
||||
`SELECT e.*, f.name as folder_name, d.name as database_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folders f ON e.folder_id = f.id
|
||||
LEFT JOIN databases d ON e.database_id = d.id`
|
||||
);
|
||||
|
||||
let synced = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const ep of result.rows) {
|
||||
try {
|
||||
await this.commitEndpointFiles(ep, cfg.prod_branch || 'main', `Sync: ${ep.name}`);
|
||||
synced++;
|
||||
} catch (err: any) {
|
||||
errors.push(`${ep.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async getEndpointGiteaInfo(endpointId: string): Promise<any> {
|
||||
if (!await this.isEnabled()) return null;
|
||||
const cfg = await this.getConfig();
|
||||
if (!cfg) return null;
|
||||
|
||||
const epResult = await mainPool.query(
|
||||
`SELECT e.name, e.path, f.name as folder_name
|
||||
FROM endpoints e LEFT JOIN folders f ON e.folder_id = f.id
|
||||
WHERE e.id = $1`,
|
||||
[endpointId]
|
||||
);
|
||||
if (epResult.rows.length === 0) return null;
|
||||
|
||||
const ep = epResult.rows[0];
|
||||
const folder = ep.folder_name ? this.sanitize(ep.folder_name) : '_root';
|
||||
const repoPath = `endpoints/${folder}/${this.sanitize(ep.name)}`;
|
||||
const fileUrl = `${cfg.url}/${cfg.owner}/${cfg.repo}/src/branch/${cfg.prod_branch}/${repoPath}`;
|
||||
|
||||
const versionResult = await mainPool.query(
|
||||
`SELECT gitea_commit_sha FROM endpoint_versions
|
||||
WHERE endpoint_id = $1 AND gitea_commit_sha IS NOT NULL
|
||||
ORDER BY version_number DESC LIMIT 1`,
|
||||
[endpointId]
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
url: cfg.url,
|
||||
owner: cfg.owner,
|
||||
repo: cfg.repo,
|
||||
file_url: fileUrl,
|
||||
repo_path: repoPath,
|
||||
last_commit_sha: versionResult.rows[0]?.gitea_commit_sha || null,
|
||||
commit_url: versionResult.rows[0]?.gitea_commit_sha
|
||||
? `${cfg.url}/${cfg.owner}/${cfg.repo}/commit/${versionResult.rows[0].gitea_commit_sha}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getSyncStatus(): Promise<{ total: number; synced: number; unsynced: number }> {
|
||||
const totalResult = await mainPool.query('SELECT COUNT(*) FROM endpoints');
|
||||
const syncedResult = await mainPool.query(
|
||||
`SELECT COUNT(DISTINCT ev.endpoint_id) FROM endpoint_versions ev
|
||||
WHERE ev.gitea_commit_sha IS NOT NULL`
|
||||
);
|
||||
const total = parseInt(totalResult.rows[0].count);
|
||||
const synced = parseInt(syncedResult.rows[0].count);
|
||||
return { total, synced, unsynced: total - synced };
|
||||
}
|
||||
|
||||
private sanitize(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').trim();
|
||||
}
|
||||
}
|
||||
|
||||
export const giteaService = new GiteaService();
|
||||
@@ -185,7 +185,7 @@ export class IsolatedScriptExecutor {
|
||||
|
||||
// Capped setTimeout/clearTimeout
|
||||
const timerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
sandbox.setTimeout = (fn: Function, ms: number, ...args: any[]) => {
|
||||
sandbox.setTimeout = (fn: (...args: any[]) => void, ms: number, ...args: any[]) => {
|
||||
const cappedMs = Math.min(ms || 0, 30000);
|
||||
const id = setTimeout(() => {
|
||||
timerIds.delete(id);
|
||||
|
||||
64
backend/src/services/SettingsService.ts
Normal file
64
backend/src/services/SettingsService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { mainPool } from '../config/database';
|
||||
import { GiteaSettings } from '../types';
|
||||
import { config } from '../config/environment';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(config.jwt.secret + ':settings').digest();
|
||||
}
|
||||
|
||||
function encrypt(text: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex');
|
||||
}
|
||||
|
||||
function decrypt(data: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const [ivHex, authTagHex, encryptedHex] = data.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const result = await mainPool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return result.rows[0].value as T;
|
||||
}
|
||||
|
||||
async set(key: string, value: any, userId?: string): Promise<void> {
|
||||
await mainPool.query(
|
||||
`INSERT INTO app_settings (key, value, updated_at, updated_by)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP, updated_by = $3`,
|
||||
[key, JSON.stringify(value), userId || null]
|
||||
);
|
||||
}
|
||||
|
||||
async getGiteaSettings(): Promise<GiteaSettings | null> {
|
||||
const raw = await this.get<any>('gitea');
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return { ...raw, token: raw.token ? decrypt(raw.token) : '' };
|
||||
} catch {
|
||||
return { ...raw, token: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async setGiteaSettings(settings: GiteaSettings, userId: string): Promise<void> {
|
||||
const toStore = { ...settings, token: settings.token ? encrypt(settings.token) : '' };
|
||||
await this.set('gitea', toStore, userId);
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsService = new SettingsService();
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mainPool } from '../config/database';
|
||||
import { EndpointVersion, VersionStatus } from '../types';
|
||||
import * as crypto from 'crypto';
|
||||
import { giteaService } from './GiteaService';
|
||||
|
||||
const SNAPSHOT_FIELDS = [
|
||||
'name', 'description', 'method', 'path', 'database_id', 'sql_query',
|
||||
@@ -81,6 +82,7 @@ class VersionService {
|
||||
updateParams
|
||||
);
|
||||
|
||||
this.syncToGitea(endpointId, version, status).catch(() => {});
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -140,10 +142,11 @@ class VersionService {
|
||||
[nextVersion, version.id, endpointId]
|
||||
);
|
||||
|
||||
this.syncToGitea(endpointId, version, 'draft').catch(() => {});
|
||||
return version;
|
||||
}
|
||||
|
||||
async publishVersion(versionId: string, userId: string): Promise<void> {
|
||||
async publishVersion(versionId: string, _userId: string): Promise<void> {
|
||||
const vResult = await mainPool.query(
|
||||
'SELECT * FROM endpoint_versions WHERE id = $1',
|
||||
[versionId]
|
||||
@@ -188,6 +191,8 @@ class VersionService {
|
||||
versionId, version.endpoint_id
|
||||
]
|
||||
);
|
||||
|
||||
this.syncToGitea(version.endpoint_id, version, 'published').catch(() => {});
|
||||
}
|
||||
|
||||
async rollbackToVersion(
|
||||
@@ -301,6 +306,46 @@ class VersionService {
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async syncToGitea(endpointId: string, version: EndpointVersion, status: VersionStatus): Promise<void> {
|
||||
try {
|
||||
if (!await giteaService.isEnabled()) return;
|
||||
|
||||
const cfg = await giteaService.getConfig();
|
||||
if (!cfg) return;
|
||||
|
||||
const epResult = await mainPool.query(
|
||||
`SELECT e.*, f.name as folder_name, d.name as database_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folders f ON e.folder_id = f.id
|
||||
LEFT JOIN databases d ON e.database_id = d.id
|
||||
WHERE e.id = $1`,
|
||||
[endpointId]
|
||||
);
|
||||
if (epResult.rows.length === 0) return;
|
||||
|
||||
const ep = { ...epResult.rows[0], ...version };
|
||||
const branch = status === 'published'
|
||||
? cfg.prod_branch || 'main'
|
||||
: `drafts/${giteaService['sanitize'](version.name)}`;
|
||||
|
||||
if (status === 'draft') {
|
||||
try { await giteaService.createBranch(branch, cfg.prod_branch || 'main'); } catch { /* branch may already exist */ }
|
||||
}
|
||||
|
||||
const message = `v${version.version_number}: ${version.change_message || status}`;
|
||||
const sha = await giteaService.commitEndpointFiles(ep, branch, message);
|
||||
|
||||
if (sha) {
|
||||
await mainPool.query(
|
||||
'UPDATE endpoint_versions SET gitea_commit_sha = $1 WHERE id = $2',
|
||||
[sha, version.id]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Gitea sync failed (non-blocking):', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const versionService = new VersionService();
|
||||
|
||||
@@ -138,6 +138,39 @@ export interface IsolatedExecutionResult {
|
||||
queries: QueryExecution[];
|
||||
}
|
||||
|
||||
export interface GiteaSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
prod_branch: string;
|
||||
}
|
||||
|
||||
export interface GiteaBranch {
|
||||
name: string;
|
||||
commit: { id: string; message: string; timestamp: string };
|
||||
}
|
||||
|
||||
export interface GiteaCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: { name: string; email: string; date: string };
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaDiff {
|
||||
total_commits: number;
|
||||
commits: GiteaCommit[];
|
||||
files: Array<{
|
||||
filename: string;
|
||||
status: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
patch?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ScriptExecutionError extends Error {
|
||||
logs: LogEntry[];
|
||||
queries: QueryExecution[];
|
||||
|
||||
44
docker-compose.gitea.yml
Normal file
44
docker-compose.gitea.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
# ============================================
|
||||
# KIS API Builder - Optional Gitea Integration
|
||||
# ============================================
|
||||
# Use alongside the main docker-compose:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gitea.yml up -d
|
||||
#
|
||||
# For existing Gitea: skip this file, configure via Settings UI
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22-rootless
|
||||
container_name: kis-api-builder-gitea
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__ROOT_URL=${GITEA_URL:-http://localhost:${GITEA_PORT:-8010}}
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
volumes:
|
||||
- gitea_data:/var/lib/gitea
|
||||
- gitea_config:/etc/gitea
|
||||
ports:
|
||||
- "${GITEA_PORT:-8010}:3000"
|
||||
networks:
|
||||
- kis-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/version"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
app:
|
||||
environment:
|
||||
GITEA_URL: ${GITEA_URL:-http://gitea:3000}
|
||||
depends_on:
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
gitea_config:
|
||||
19
frontend/.eslintrc.cjs
Normal file
19
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'prefer-const': 'warn',
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { endpointsApi, foldersApi, databasesApi, versionsApi } from '@/services/api';
|
||||
import { endpointsApi, foldersApi, databasesApi, versionsApi, giteaApi } from '@/services/api';
|
||||
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload, GitBranch } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
@@ -105,7 +105,7 @@ export default function EndpointEditor() {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) return JSON.parse(saved).testParams || {};
|
||||
} catch {}
|
||||
} catch { /* ignore corrupt localStorage */ }
|
||||
}
|
||||
return {};
|
||||
});
|
||||
@@ -134,7 +134,7 @@ export default function EndpointEditor() {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.testResult) setTestResult(parsed.testResult);
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function EndpointEditor() {
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult }));
|
||||
} catch {}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [storageKey, testParams, testResult]);
|
||||
|
||||
@@ -242,6 +242,12 @@ export default function EndpointEditor() {
|
||||
});
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const { data: giteaInfo } = useQuery({
|
||||
queryKey: ['gitea-info', id],
|
||||
queryFn: () => giteaApi.getEndpointInfo(id!).then(r => r.data),
|
||||
enabled: isEditing,
|
||||
});
|
||||
|
||||
const draftMutation = useMutation({
|
||||
mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }),
|
||||
onSuccess: () => {
|
||||
@@ -1099,6 +1105,44 @@ export default function EndpointEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gitea info */}
|
||||
{isEditing && giteaInfo?.enabled && (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-3">
|
||||
<GitBranch size={16} />
|
||||
<span>Gitea</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{giteaInfo.file_url && (
|
||||
<a
|
||||
href={giteaInfo.file_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-primary-600 hover:text-primary-700 truncate"
|
||||
>
|
||||
View in Gitea
|
||||
</a>
|
||||
)}
|
||||
{giteaInfo.last_commit_sha && (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<span>Last commit:</span>
|
||||
<a
|
||||
href={giteaInfo.commit_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{giteaInfo.last_commit_sha.slice(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!giteaInfo.last_commit_sha && (
|
||||
<span className="text-gray-400">Not yet synced to Gitea</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test results */}
|
||||
{testResult && (
|
||||
<div className="card overflow-hidden">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usersApi, dbManagementApi } from '@/services/api';
|
||||
import { usersApi, dbManagementApi, giteaApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users } from 'lucide-react';
|
||||
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users, GitBranch } from 'lucide-react';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
|
||||
@@ -216,7 +216,7 @@ function PasswordTab({ currentUser }: { currentUser: any }) {
|
||||
}
|
||||
|
||||
function GlobalSettingsTab() {
|
||||
const [subTab, setSubTab] = useState<'databases' | 'users'>('databases');
|
||||
const [subTab, setSubTab] = useState<'databases' | 'users' | 'gitea'>('databases');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -244,11 +244,262 @@ function GlobalSettingsTab() {
|
||||
<Users className="inline mr-2" size={16} />
|
||||
Пользователи
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('gitea')}
|
||||
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||
subTab === 'gitea'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="inline mr-2" size={16} />
|
||||
Gitea
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{subTab === 'databases' && <DatabasesSubTab />}
|
||||
{subTab === 'users' && <UsersSubTab />}
|
||||
{subTab === 'gitea' && <GiteaSubTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaSubTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['gitea-settings'],
|
||||
queryFn: () => giteaApi.getSettings().then(r => r.data),
|
||||
});
|
||||
|
||||
const { data: syncStatus } = useQuery({
|
||||
queryKey: ['gitea-sync-status'],
|
||||
queryFn: () => giteaApi.getSyncStatus().then(r => r.data),
|
||||
enabled: !!settings?.enabled,
|
||||
});
|
||||
|
||||
const { data: branches } = useQuery({
|
||||
queryKey: ['gitea-branches'],
|
||||
queryFn: () => giteaApi.listBranches().then(r => r.data),
|
||||
enabled: !!settings?.enabled,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
enabled: false, url: '', token: '', owner: '', repo: '', prod_branch: 'main',
|
||||
});
|
||||
|
||||
useState(() => {
|
||||
if (settings) setFormData(settings);
|
||||
});
|
||||
|
||||
// Sync form when settings load
|
||||
const prevSettings = settings;
|
||||
if (prevSettings && formData.url === '' && prevSettings.url !== '') {
|
||||
setFormData(prevSettings);
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => giteaApi.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-sync-status'] });
|
||||
toast.success('Gitea settings saved');
|
||||
},
|
||||
onError: () => toast.error('Failed to save Gitea settings'),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => giteaApi.testConnection(),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.success) toast.success(res.data.message);
|
||||
else toast.error(res.data.message);
|
||||
},
|
||||
onError: () => toast.error('Connection test failed'),
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => giteaApi.syncAll(),
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Synced ${res.data.synced} endpoints${res.data.errors?.length ? `, ${res.data.errors.length} errors` : ''}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-sync-status'] });
|
||||
},
|
||||
onError: () => toast.error('Sync failed'),
|
||||
});
|
||||
|
||||
const createBranchMutation = useMutation({
|
||||
mutationFn: (name: string) => giteaApi.createBranch(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||
toast.success('Branch created');
|
||||
},
|
||||
onError: () => toast.error('Failed to create branch'),
|
||||
});
|
||||
|
||||
const deleteBranchMutation = useMutation({
|
||||
mutationFn: (name: string) => giteaApi.deleteBranch(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gitea-branches'] });
|
||||
toast.success('Branch deleted');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete branch'),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1">Gitea Integration</h3>
|
||||
<p className="text-sm text-gray-600">Version control for endpoints via Gitea (branches, merges, diffs, PR)</p>
|
||||
</div>
|
||||
|
||||
{/* Connection form */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 space-y-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Enable Gitea integration</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Gitea URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="http://gitea:3000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Token</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.token}
|
||||
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
||||
className="input w-full pr-10"
|
||||
placeholder="gitea_api_token"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded">
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Owner (user/org)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.owner}
|
||||
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="myorg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Repository</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.repo}
|
||||
onChange={(e) => setFormData({ ...formData, repo: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="kis-endpoints"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prod Branch</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.prod_branch}
|
||||
onChange={(e) => setFormData({ ...formData, prod_branch: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={() => saveMutation.mutate(formData)} disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={() => testMutation.mutate()} disabled={testMutation.isPending} className="btn btn-secondary">
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync status */}
|
||||
{settings?.enabled && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Sync Status</h4>
|
||||
{syncStatus && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{syncStatus.synced}/{syncStatus.total} endpoints synced
|
||||
{syncStatus.unsynced > 0 && <span className="text-orange-600 ml-1">({syncStatus.unsynced} unsynced)</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending} className="btn btn-secondary text-sm">
|
||||
{syncMutation.isPending ? 'Syncing...' : 'Sync All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branches */}
|
||||
{settings?.enabled && branches && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">Branches ({branches.length})</h4>
|
||||
<button
|
||||
onClick={() => {
|
||||
const name = prompt('Branch name:');
|
||||
if (name) createBranchMutation.mutate(name);
|
||||
}}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
>
|
||||
<Plus size={14} /> New Branch
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{branches.map((b: any) => (
|
||||
<div key={b.name} className="flex items-center justify-between text-sm border border-gray-100 rounded-lg p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={14} className="text-gray-500" />
|
||||
<span className="font-mono font-medium">{b.name}</span>
|
||||
{b.name === (settings?.prod_branch || 'main') && (
|
||||
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded">prod</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono">{b.commit?.id?.slice(0, 7)}</span>
|
||||
{b.name !== (settings?.prod_branch || 'main') && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Delete branch ${b.name}?`)) deleteBranchMutation.mutate(b.name); }}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion } from '@/types';
|
||||
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion, GiteaSettings, GiteaBranch, GiteaCommit, GiteaEndpointInfo } from '@/types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -164,6 +164,23 @@ export const versionsApi = {
|
||||
api.get<EndpointVersion>(`/endpoints/${endpointId}/draft`),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
export const giteaApi = {
|
||||
getSettings: () => api.get<GiteaSettings>('/gitea/settings'),
|
||||
updateSettings: (data: Partial<GiteaSettings>) => api.put('/gitea/settings', data),
|
||||
testConnection: () => api.post<{ success: boolean; message: string }>('/gitea/test'),
|
||||
syncAll: () => api.post<{ synced: number; errors: string[] }>('/gitea/sync-all'),
|
||||
getSyncStatus: () => api.get<{ total: number; synced: number; unsynced: number }>('/gitea/sync-status'),
|
||||
listBranches: () => api.get<GiteaBranch[]>('/gitea/branches'),
|
||||
createBranch: (name: string, from?: string) => api.post('/gitea/branches', { name, from }),
|
||||
deleteBranch: (name: string) => api.delete(`/gitea/branches/${encodeURIComponent(name)}`),
|
||||
compareBranches: (base: string, head: string) => api.get('/gitea/compare', { params: { base, head } }),
|
||||
getCommitHistory: (path?: string, branch?: string) => api.get<GiteaCommit[]>('/gitea/commits', { params: { path, branch } }),
|
||||
getEndpointInfo: (endpointId: string) => api.get<GiteaEndpointInfo>(`/gitea/endpoints/${endpointId}/info`),
|
||||
createPR: (data: { title: string; head: string; base?: string; body?: string }) => api.post('/gitea/pulls', data),
|
||||
mergePR: (index: number) => api.post(`/gitea/pulls/${index}/merge`),
|
||||
};
|
||||
|
||||
// Folders API
|
||||
export const foldersApi = {
|
||||
getAll: () =>
|
||||
|
||||
@@ -134,6 +134,38 @@ export interface QueryTestResult {
|
||||
processedQuery?: string;
|
||||
}
|
||||
|
||||
export interface GiteaSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
prod_branch: string;
|
||||
}
|
||||
|
||||
export interface GiteaBranch {
|
||||
name: string;
|
||||
commit: { id: string; message: string; timestamp: string };
|
||||
}
|
||||
|
||||
export interface GiteaCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: { name: string; email: string; date: string };
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaEndpointInfo {
|
||||
enabled: boolean;
|
||||
url?: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
file_url?: string;
|
||||
repo_path?: string;
|
||||
last_commit_sha?: string;
|
||||
commit_url?: string;
|
||||
}
|
||||
|
||||
export type VersionStatus = 'draft' | 'published' | 'archived';
|
||||
|
||||
export interface EndpointVersion {
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"start:prod": "cd backend && pm2 start dist/server.js --name kis-api-builder-backend --env production",
|
||||
"reload:prod": "npm run build && cd backend && pm2 reload kis-api-builder-backend",
|
||||
"stop:prod": "cd backend && pm2 stop kis-api-builder-backend",
|
||||
"delete:prod": "cd backend && pm2 delete kis-api-builder-backend"
|
||||
"delete:prod": "cd backend && pm2 delete kis-api-builder-backend",
|
||||
"lint": "cd backend && npx eslint src/ --ext ts && cd ../frontend && npx eslint src/ --ext ts,tsx",
|
||||
"lint:fix": "cd backend && npx eslint src/ --ext ts --fix && cd ../frontend && npx eslint src/ --ext ts,tsx --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
Reference in New Issue
Block a user