@@ -17,6 +17,7 @@ const { deepMergeSettings } = require('../../core/utils/settingsMerger');
1717const { checkBotAccess } = require ( '../middleware/botAccess' ) ;
1818const { filterSecretSettings, prepareSettingsForSave, isGroupedSettings } = require ( '../../core/utils/secretsFilter' ) ;
1919const PluginHooks = require ( '../../core/PluginHooks' ) ;
20+ const rateLimit = require ( 'express-rate-limit' ) ;
2021
2122const multer = require ( 'multer' ) ;
2223const archiver = require ( 'archiver' ) ;
@@ -26,6 +27,160 @@ const os = require('os');
2627const upload = multer ( { storage : multer . memoryStorage ( ) } ) ;
2728
2829const router = express . Router ( ) ;
30+ const GITHUB_REQUEST_TIMEOUT_MS = 10000 ;
31+ const GITHUB_OWNER_REPO_PATTERN = / ^ [ A - Z a - z 0 - 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 ( / \. g i t $ / 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
30185const 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 || ( / r e q u i r e d | i n v a l i d | o n l y g i t h u b | m u s t i n c l u d e | u n s u p p o r t e d / 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 = / r e q u i r e d | i n v a l i d | o n l y g i t h u b | m u s t i n c l u d e | u n s u p p o r t e d / i. test ( error . message ) ? 400 : 500 ;
861+ let message = error . message ;
862+
863+ if ( / s t a t u s : \s * 4 0 4 | с т а т у с : \s * 4 0 4 / 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 ( / s t a t u s : \s * 4 0 3 | с т а т у с : \s * 4 0 3 / i. test ( message ) ) {
869+ status = 503 ;
870+ message = 'GitHub temporarily refused the request or rate limit was exceeded. Try again later.' ;
871+ } else if ( / p a c k a g e \. j s o n / i. test ( message ) ) {
872+ status = 400 ;
873+ message = 'The GitHub repository does not contain a valid plugin package.json.' ;
874+ } else if ( / f e t c h / 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