Skip to content

Commit d28e0fa

Browse files
authored
feat: большая переработка интерфейса и прочее
feat: enhance plugin workflows, theming, and panel ux
2 parents 6f925ed + d52e9b3 commit d28e0fa

182 files changed

Lines changed: 11155 additions & 4761 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "InstalledPlugin" ADD COLUMN "sourceRefType" TEXT;
2+
ALTER TABLE "InstalledPlugin" ADD COLUMN "sourceRef" TEXT;

backend/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ model InstalledPlugin {
7979
description String?
8080
sourceType String
8181
sourceUri String?
82+
sourceRefType String?
83+
sourceRef String?
8284
path String
8385
isEnabled Boolean @default(true)
8486

backend/src/api/routes/bots.js

Lines changed: 253 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const { deepMergeSettings } = require('../../core/utils/settingsMerger');
1717
const { checkBotAccess } = require('../middleware/botAccess');
1818
const { filterSecretSettings, prepareSettingsForSave, isGroupedSettings } = require('../../core/utils/secretsFilter');
1919
const PluginHooks = require('../../core/PluginHooks');
20+
const rateLimit = require('express-rate-limit');
2021

2122
const multer = require('multer');
2223
const archiver = require('archiver');
@@ -26,6 +27,160 @@ const os = require('os');
2627
const upload = multer({ storage: multer.memoryStorage() });
2728

2829
const router = express.Router();
30+
const GITHUB_REQUEST_TIMEOUT_MS = 10000;
31+
const GITHUB_OWNER_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
32+
33+
const githubPreviewLimiter = rateLimit({
34+
windowMs: 60 * 1000,
35+
max: 30,
36+
standardHeaders: true,
37+
legacyHeaders: false,
38+
message: { message: 'Too many GitHub preview requests. Try again later.' },
39+
});
40+
41+
const githubInstallLimiter = rateLimit({
42+
windowMs: 60 * 1000,
43+
max: 20,
44+
standardHeaders: true,
45+
legacyHeaders: false,
46+
message: { message: 'Too many GitHub install requests. Try again later.' },
47+
});
48+
49+
function getGithubHeaders(extra = {}) {
50+
const headers = {
51+
'Accept': 'application/vnd.github+json',
52+
'User-Agent': 'BlockMine',
53+
...extra
54+
};
55+
56+
if (process.env.GITHUB_TOKEN) {
57+
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
58+
}
59+
60+
return headers;
61+
}
62+
63+
function parseGithubRepoUrl(repoUrl) {
64+
if (typeof repoUrl !== 'string') {
65+
throw new Error('Repository URL is required.');
66+
}
67+
68+
const trimmed = repoUrl.trim();
69+
if (!trimmed) {
70+
throw new Error('Repository URL is required.');
71+
}
72+
73+
let parsedUrl;
74+
try {
75+
parsedUrl = new URL(trimmed);
76+
} catch (error) {
77+
throw new Error('Invalid GitHub repository URL.');
78+
}
79+
80+
if (!['github.com', 'www.github.com'].includes(parsedUrl.hostname)) {
81+
throw new Error('Only GitHub repository links are supported.');
82+
}
83+
84+
const pathParts = parsedUrl.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
85+
if (pathParts.length < 2) {
86+
throw new Error('GitHub repository URL must include owner and repository name.');
87+
}
88+
89+
const owner = pathParts[0];
90+
const repo = pathParts[1].replace(/\.git$/i, '');
91+
92+
if (!owner || !repo) {
93+
throw new Error('GitHub repository URL must include owner and repository name.');
94+
}
95+
if (!GITHUB_OWNER_REPO_PATTERN.test(owner) || !GITHUB_OWNER_REPO_PATTERN.test(repo)) {
96+
throw new Error('GitHub repository URL contains unsupported owner or repository characters.');
97+
}
98+
99+
return {
100+
owner,
101+
repo,
102+
normalizedUrl: `https://github.com/${owner}/${repo}`
103+
};
104+
}
105+
106+
function normalizeGithubRepoUrl(repoUrl) {
107+
return parseGithubRepoUrl(repoUrl).normalizedUrl;
108+
}
109+
110+
async function fetchGithubJson(url) {
111+
const controller = new AbortController();
112+
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
113+
let response;
114+
try {
115+
response = await fetch(url, {
116+
headers: getGithubHeaders(),
117+
signal: controller.signal
118+
});
119+
} finally {
120+
clearTimeout(timeoutId);
121+
}
122+
123+
if (!response.ok) {
124+
const error = new Error(`GitHub API request failed with status ${response.status}.`);
125+
error.status = response.status;
126+
throw error;
127+
}
128+
129+
return response.json();
130+
}
131+
132+
async function fetchGithubReadme(owner, repo) {
133+
const controller = new AbortController();
134+
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
135+
let response;
136+
try {
137+
response = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
138+
headers: getGithubHeaders({ 'Accept': 'application/vnd.github.raw+json' }),
139+
signal: controller.signal
140+
});
141+
} finally {
142+
clearTimeout(timeoutId);
143+
}
144+
145+
if (!response.ok) {
146+
return null;
147+
}
148+
149+
return response.text();
150+
}
151+
152+
async function renderGithubMarkdown(markdown, owner, repo) {
153+
if (!markdown) {
154+
return null;
155+
}
156+
157+
const controller = new AbortController();
158+
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
159+
let response;
160+
try {
161+
response = await fetch('https://api.github.com/markdown', {
162+
method: 'POST',
163+
headers: getGithubHeaders({
164+
'Accept': 'text/html',
165+
'Content-Type': 'application/json',
166+
}),
167+
signal: controller.signal,
168+
body: JSON.stringify({
169+
text: markdown,
170+
mode: 'gfm',
171+
context: `${owner}/${repo}`
172+
})
173+
});
174+
} finally {
175+
clearTimeout(timeoutId);
176+
}
177+
178+
if (!response.ok) {
179+
return null;
180+
}
181+
182+
return response.text();
183+
}
29184

30185
const conditionalRestartAuth = (req, res, next) => {
31186
if (process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development') {
@@ -622,14 +777,108 @@ router.get('/:botId/plugins', authenticateUniversal, checkBotAccess, authorize('
622777
} catch (error) { res.status(500).json({ error: 'Не удалось получить плагины бота' }); }
623778
});
624779

625-
router.post('/:botId/plugins/install/github', authenticateUniversal, checkBotAccess, authorize('plugin:install'), async (req, res) => {
626-
const { botId } = req.params;
780+
router.post('/:botId/plugins/install/github/preview', githubPreviewLimiter, authenticateUniversal, checkBotAccess, authorize('plugin:install'), async (req, res) => {
627781
const { repoUrl } = req.body;
782+
783+
try {
784+
const { owner, repo, normalizedUrl } = parseGithubRepoUrl(repoUrl);
785+
const repoInfo = await fetchGithubJson(`https://api.github.com/repos/${owner}/${repo}`);
786+
787+
let tags = [];
788+
let latestRelease = null;
789+
let readme = null;
790+
let readmeHtml = null;
791+
792+
try {
793+
const tagsData = await fetchGithubJson(`https://api.github.com/repos/${owner}/${repo}/tags?per_page=20`);
794+
tags = Array.isArray(tagsData) ? tagsData.map(tag => ({
795+
name: tag.name,
796+
sha: tag.commit?.sha || null
797+
})) : [];
798+
} catch (error) {
799+
console.warn(`[GitHub Preview] Failed to load tags for ${owner}/${repo}:`, error.message);
800+
}
801+
802+
try {
803+
latestRelease = await fetchGithubJson(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
804+
} catch (error) {
805+
console.warn(`[GitHub Preview] Failed to load latest release for ${owner}/${repo}:`, error.message);
806+
}
807+
808+
try {
809+
readme = await fetchGithubReadme(owner, repo);
810+
} catch (error) {
811+
console.warn(`[GitHub Preview] Failed to load README for ${owner}/${repo}:`, error.message);
812+
}
813+
814+
try {
815+
readmeHtml = await renderGithubMarkdown(readme, owner, repo);
816+
} catch (error) {
817+
console.warn(`[GitHub Preview] Failed to render README for ${owner}/${repo}:`, error.message);
818+
}
819+
820+
res.json({
821+
repo: {
822+
name: repoInfo.name,
823+
fullName: repoInfo.full_name,
824+
description: repoInfo.description || '',
825+
defaultBranch: repoInfo.default_branch,
826+
stars: repoInfo.stargazers_count || 0,
827+
htmlUrl: repoInfo.html_url || normalizedUrl,
828+
visibility: repoInfo.private ? 'private' : 'public'
829+
},
830+
latestReleaseTag: latestRelease?.tag_name || null,
831+
tags,
832+
readme,
833+
readmeHtml
834+
});
835+
} catch (error) {
836+
if (error.name === 'AbortError') {
837+
return res.status(504).json({ message: 'GitHub request timed out. Please try again.' });
838+
}
839+
if (error.status === 404) {
840+
return res.status(404).json({ message: 'GitHub repository not found or it is private.' });
841+
}
842+
if (error.status === 403) {
843+
return res.status(503).json({ message: 'GitHub API rate limit exceeded. Try again a bit later.' });
844+
}
845+
const status = error.status || (/required|invalid|only github|must include|unsupported/i.test(error.message) ? 400 : 500);
846+
res.status(status).json({ message: error.message });
847+
}
848+
});
849+
850+
router.post('/:botId/plugins/install/github', githubInstallLimiter, authenticateUniversal, checkBotAccess, authorize('plugin:install'), async (req, res) => {
851+
const { botId } = req.params;
852+
const { repoUrl, tag } = req.body;
853+
let normalizedRepoUrl = repoUrl;
854+
const normalizedTag = typeof tag === 'string' && tag.trim() ? tag.trim() : null;
628855
try {
629-
const newPlugin = await pluginManager.installFromGithub(parseInt(botId), repoUrl);
856+
normalizedRepoUrl = normalizeGithubRepoUrl(repoUrl);
857+
const newPlugin = await pluginManager.installFromGithub(parseInt(botId), normalizedRepoUrl, prisma, false, normalizedTag);
630858
res.status(201).json(newPlugin);
631859
} catch (error) {
632-
res.status(500).json({ message: error.message });
860+
let status = /required|invalid|only github|must include|unsupported/i.test(error.message) ? 400 : 500;
861+
let message = error.message;
862+
863+
if (/status:\s*404|статус:\s*404/i.test(message)) {
864+
status = 404;
865+
message = normalizedTag
866+
? `GitHub tag "${normalizedTag}" was not found in ${normalizedRepoUrl}.`
867+
: `GitHub repository was not found or is private: ${normalizedRepoUrl}.`;
868+
} else if (/status:\s*403|статус:\s*403/i.test(message)) {
869+
status = 503;
870+
message = 'GitHub temporarily refused the request or rate limit was exceeded. Try again later.';
871+
} else if (/package\.json/i.test(message)) {
872+
status = 400;
873+
message = 'The GitHub repository does not contain a valid plugin package.json.';
874+
} else if (/fetch/i.test(message)) {
875+
status = 502;
876+
message = normalizedTag
877+
? `Failed to connect to GitHub while downloading tag "${normalizedTag}".`
878+
: 'Failed to connect to GitHub while downloading the repository.';
879+
}
880+
881+
res.status(status).json({ message });
633882
}
634883
});
635884

0 commit comments

Comments
 (0)