@@ -6,10 +6,13 @@ import express from "express"
66import ratelimit from "express-rate-limit"
77import cache from "memory-cache"
88import util from "util"
9+ import url from "url"
10+ import axios from "axios"
911import mocks from "../../../tests/mocks/index.mjs"
1012import metrics from "../metrics/index.mjs"
1113import presets from "../metrics/presets.mjs"
1214import setup from "../metrics/setup.mjs"
15+ import crypto from "crypto"
1316
1417/**App */
1518export default async function ( { sandbox = false } = { } ) {
@@ -55,7 +58,20 @@ export default async function({sandbox = false} = {}) {
5558 //Apply mocking if needed
5659 if ( mock )
5760 Object . assign ( api , await mocks ( api ) )
58- const { graphql, rest} = api
61+ //Custom user octokits sessions
62+ const authenticated = new Map ( )
63+ const uapi = session => {
64+ if ( ! / ^ [ a - f 0 - 9 ] + $ / i. test ( `${ session } ` ) )
65+ return null
66+ if ( authenticated . has ( session ) ) {
67+ const { login, token} = authenticated . get ( session )
68+ console . debug ( `metrics/app/session/${ login } > authenticated with session ${ session . substring ( 0 , 6 ) } , using custom octokit` )
69+ return { login, graphql : octokit . graphql . defaults ( { headers : { authorization : `token ${ token } ` } } ) , rest : new OctokitRest . Octokit ( { auth : token } ) }
70+ }
71+ else if ( session )
72+ console . debug ( `metrics/app/session > unknown session ${ session . substring ( 0 , 6 ) } , using default octokit` )
73+ return null
74+ }
5975
6076 //Setup server
6177 const app = express ( )
@@ -87,22 +103,19 @@ export default async function({sandbox = false} = {}) {
87103 const limiter = ratelimit ( { max : debug ? Number . MAX_SAFE_INTEGER : 60 , windowMs : 60 * 1000 , headers : false } )
88104 const metadata = Object . fromEntries (
89105 Object . entries ( conf . metadata . plugins )
90- . map ( ( [ key , value ] ) => [ key , Object . fromEntries ( Object . entries ( value ) . filter ( ( [ key ] ) => [ "name" , "icon" , "category" , "web" , "supports" , "scopes" ] . includes ( key ) ) ) ] )
106+ . map ( ( [ key , value ] ) => [ key , Object . fromEntries ( Object . entries ( value ) . filter ( ( [ key ] ) => [ "name" , "icon" , "category" , "web" , "supports" , "scopes" , "deprecated" ] . includes ( key ) ) ) ] )
91107 . map ( ( [ key , value ] ) => [ key , key === "core" ? { ...value , web : Object . fromEntries ( Object . entries ( value . web ) . filter ( ( [ key ] ) => / ^ c o n f i g [ . ] / . test ( key ) ) . map ( ( [ key , value ] ) => [ key . replace ( / ^ c o n f i g [ . ] / , "" ) , value ] ) ) } : value ] ) ,
92108 )
93- const enabled = Object . entries ( metadata ) . filter ( ( [ _name , { category} ] ) => category !== "core" ) . map ( ( [ name ] ) => ( { name, category : metadata [ name ] ?. category ?? "community" , enabled : plugins [ name ] ?. enabled ?? false } ) )
109+ const enabled = Object . entries ( metadata ) . filter ( ( [ _name , { category} ] ) => category !== "core" ) . map ( ( [ name ] ) => ( { name, category : metadata [ name ] ?. category ?? "community" , deprecated : metadata [ name ] ?. deprecated ?? false , enabled : plugins [ name ] ?. enabled ?? false } ) )
94110 const templates = Object . entries ( Templates ) . map ( ( [ name ] ) => ( { name, enabled : ( conf . settings . templates . enabled . length ? conf . settings . templates . enabled . includes ( name ) : true ) ?? false } ) )
95111 const actions = { flush : new Map ( ) }
96- const requests = { rest : { limit : 0 , used : 0 , remaining : 0 , reset : NaN } , graphql : { limit : 0 , used : 0 , remaining : 0 , reset : NaN } }
112+ const requests = { rest : { limit : 0 , used : 0 , remaining : 0 , reset : NaN } , graphql : { limit : 0 , used : 0 , remaining : 0 , reset : NaN } , search : { limit : 0 , used : 0 , remaining : 0 , reset : NaN } }
97113 let _requests_refresh = false
98114 if ( ! conf . settings . notoken ) {
99115 const refresh = async ( ) => {
100116 try {
101- const { limit} = await graphql ( "{ limit:rateLimit {limit remaining reset:resetAt used} }" )
102- Object . assign ( requests , {
103- rest : ( await rest . rateLimit . get ( ) ) . data . rate ,
104- graphql : { ...limit , reset : new Date ( limit . reset ) . getTime ( ) } ,
105- } )
117+ const { resources} = ( await api . rest . rateLimit . get ( ) ) . data
118+ Object . assign ( requests , { rest : resources . core , graphql : resources . graphql , search : resources . search } )
106119 }
107120 catch {
108121 console . debug ( "metrics/app > failed to update remaining requests" )
@@ -130,8 +143,16 @@ export default async function({sandbox = false} = {}) {
130143 app . get ( "/.templates/:template" , limiter , ( req , res ) => req . params . template in conf . templates ? res . status ( 200 ) . json ( conf . templates [ req . params . template ] ) : res . sendStatus ( 404 ) )
131144 for ( const template in conf . templates )
132145 app . use ( `/.templates/${ template } /partials` , express . static ( `${ conf . paths . templates } /${ template } /partials` ) )
133- //Modes
146+ //Modes and extras
134147 app . get ( "/.modes" , limiter , ( req , res ) => res . status ( 200 ) . json ( conf . settings . modes ) )
148+ app . get ( "/.extras" , limiter , async ( req , res ) => {
149+ if ( ( authenticated . has ( req . headers [ "x-metrics-session" ] ) ) && ( conf . settings . extras ?. logged ) ) {
150+ if ( conf . settings . extras ?. features !== true )
151+ return res . status ( 200 ) . json ( [ ...conf . settings . extras . features , ...conf . settings . extras . logged ] )
152+ }
153+ res . status ( 200 ) . json ( conf . settings . extras ?. features ?? conf . settings ?. extras ?. default ?? false )
154+ } )
155+ app . get ( "/.extras.logged" , limiter , async ( req , res ) => res . status ( 200 ) . json ( conf . settings . extras ?. logged ?? [ ] ) )
135156 //Styles
136157 app . get ( "/.css/style.css" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /style.css` ) )
137158 app . get ( "/.css/style.vars.css" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /style.vars.css` ) )
@@ -152,7 +173,18 @@ export default async function({sandbox = false} = {}) {
152173 app . get ( "/.js/clipboard.min.js" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . node_modules } /clipboard/dist/clipboard.min.js` ) )
153174 //Meta
154175 app . get ( "/.version" , limiter , ( req , res ) => res . status ( 200 ) . send ( conf . package . version ) )
155- app . get ( "/.requests" , limiter , ( req , res ) => res . status ( 200 ) . json ( requests ) )
176+ app . get ( "/.requests" , limiter , async ( req , res ) => {
177+ try {
178+ const custom = uapi ( req . headers [ "x-metrics-session" ] )
179+ if ( custom ) {
180+ const { data :{ resources} } = await custom . rest . rateLimit . get ( )
181+ if ( resources )
182+ return res . status ( 200 ) . json ( { rest :resources . core , graphql :resources . graphql , search :resources . search , login :custom . login } )
183+ }
184+ }
185+ catch { } //eslint-disable-line no-empty
186+ return res . status ( 200 ) . json ( requests )
187+ } )
156188 app . get ( "/.hosted" , limiter , ( req , res ) => res . status ( 200 ) . json ( conf . settings . hosted || null ) )
157189 //Cache
158190 app . get ( "/.uncache" , limiter , ( req , res ) => {
@@ -172,6 +204,84 @@ export default async function({sandbox = false} = {}) {
172204 }
173205 } )
174206
207+ //OAuth
208+ if ( conf . settings . oauth ) {
209+ console . debug ( "metrics/app/oauth > enabled" )
210+ const states = new Map ( )
211+ app . get ( "/.oauth/" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /oauth/index.html` ) )
212+ app . get ( "/.oauth/index.html" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /oauth/index.html` ) )
213+ app . get ( "/.oauth/script.js" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /oauth/script.js` ) )
214+ app . get ( "/.oauth/authenticate" , ( req , res ) => {
215+ //Create a state to protect against cross-site request forgery attacks
216+ const state = crypto . randomBytes ( 64 ) . toString ( "hex" )
217+ const scopes = new url . URLSearchParams ( req . query ) . get ( "scopes" )
218+ const from = new url . URLSearchParams ( req . query ) . get ( "scopes" )
219+ states . set ( state , { from, scopes} )
220+ console . debug ( `metrics/app/oauth > request ${ state } ` )
221+ //OAuth through GitHub
222+ return res . redirect ( `https://github.com/login/oauth/authorize?${ new url . URLSearchParams ( {
223+ client_id :conf . settings . oauth . id ,
224+ state,
225+ redirect_uri :`${ conf . settings . oauth . url } /.oauth/authorize` ,
226+ allow_signup :false ,
227+ scope :scopes ,
228+ } ) } `)
229+ } )
230+ app . get ( "/.oauth/authorize" , async ( req , res ) => {
231+ //Check state
232+ const { code, state} = req . query
233+ if ( ( ! state ) || ( ! states . has ( state ) ) ) {
234+ console . debug ( "metrics/app/oauth > 400 (invalid state)" )
235+ return res . status ( 400 ) . send ( "Bad request: invalid state" )
236+ }
237+ //OAuth
238+ try {
239+ //Authorize user
240+ console . debug ( "metrics/app/oauth > authorization" )
241+ const { data} = await axios . post ( "https://github.com/login/oauth/access_token" , `${ new url . URLSearchParams ( {
242+ client_id :conf . settings . oauth . id ,
243+ client_secret :conf . settings . oauth . secret ,
244+ code,
245+ } ) } `)
246+ const token = new url . URLSearchParams ( data ) . get ( "access_token" )
247+ //Validate user
248+ const { data :{ login} } = await axios . get ( "https://api.github.com/user" , { headers :{ Authorization :`token ${ token } ` } } )
249+ console . debug ( `metrics/app/oauth > authorization success for ${ login } ` )
250+ const session = crypto . randomBytes ( 128 ) . toString ( "hex" )
251+ authenticated . set ( session , { login, token} )
252+ console . debug ( `metrics/app/oauth > created session ${ session . substring ( 0 , 6 ) } ` )
253+ //Redirect user back
254+ const { from} = states . get ( state )
255+ return res . redirect ( `/.oauth/redirect?${ new url . URLSearchParams ( { to :from , session} ) } ` )
256+ }
257+ catch {
258+ console . debug ( "metrics/app/oauth > authorization failed" )
259+ return res . status ( 401 ) . send ( "Unauthorized: oauth failed" )
260+ }
261+ finally {
262+ states . delete ( state )
263+ }
264+ } )
265+ app . get ( "/.oauth/revoke/:session" , limiter , async ( req , res ) => {
266+ const session = req . params . session ?. replace ( / [ \n \r ] / g, "" )
267+ if ( authenticated . has ( session ) ) {
268+ const { token} = authenticated . get ( session )
269+ try {
270+ console . log ( await axios . delete ( `https://api.github.com/applications/${ conf . settings . oauth . id } /grant` , { auth :{ username :conf . settings . oauth . id , password :conf . settings . oauth . secret } , headers :{ Accept :"application/vnd.github+json" } , data :{ access_token :token } } ) )
271+ authenticated . delete ( session )
272+ console . debug ( `metrics/app/oauth > deleted session ${ session . substring ( 0 , 6 ) } ` )
273+ return res . redirect ( "/.oauth" )
274+ }
275+ catch { } //eslint-disable-line no-empty
276+ }
277+ return res . status ( 400 ) . send ( "Bad request: invalid session" )
278+ } )
279+ app . get ( "/.oauth/redirect" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /oauth/redirect.html` ) )
280+ app . get ( "/.oauth/enabled" , limiter , ( req , res ) => res . json ( true ) )
281+ }
282+ else
283+ app . get ( "/.oauth/enabled" , limiter , ( req , res ) => res . json ( false ) )
284+
175285 //Pending requests
176286 const pending = new Map ( )
177287
@@ -236,7 +346,7 @@ export default async function({sandbox = false} = {}) {
236346 }
237347 ; ( async ( ) => {
238348 try {
239- const json = await metrics . insights ( { login} , { graphql , rest , conf, callbacks} , { Plugins, Templates} )
349+ const json = await metrics . insights ( { login} , { ... api , ... uapi ( req . headers [ "x-metrics-session" ] ) , conf, callbacks} , { Plugins, Templates} )
240350 //Cache
241351 cache . put ( `insights.${ login } ` , json )
242352 if ( ( ! debug ) && ( cached ) ) {
@@ -289,12 +399,14 @@ export default async function({sandbox = false} = {}) {
289399 app . get ( "/.js/embed/app.js" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /embed/app.js` ) )
290400 app . get ( "/.js/embed/app.placeholder.js" , limiter , ( req , res ) => res . sendFile ( `${ conf . paths . statics } /embed/app.placeholder.js` ) )
291401 //App routes
292- app . get ( "/:login/:repository?" , ...middlewares , async ( req , res ) => {
402+ app . get ( "/:login/:repository?" , ...middlewares , async ( req , res , next ) => {
293403 //Request params
294404 const login = req . params . login ?. replace ( / [ \n \r ] / g, "" )
295405 const repository = req . params . repository ?. replace ( / [ \n \r ] / g, "" )
296406 let solve = null
297407 //Check username
408+ if ( ( login . startsWith ( "." ) ) || ( login . includes ( "/" ) ) )
409+ return next ( )
298410 if ( ! / ^ [ - \w ] + $ / i. test ( login ) ) {
299411 console . debug ( `metrics/app/${ login } > 400 (invalid username)` )
300412 return res . status ( 400 ) . send ( "Bad request: username seems invalid" )
@@ -335,19 +447,28 @@ export default async function({sandbox = false} = {}) {
335447
336448 //Compute rendering
337449 try {
338- //Render
450+ //Prepare settings
339451 const q = req . query
340452 console . debug ( `metrics/app/${ login } > ${ util . inspect ( q , { depth : Infinity , maxStringLength : 256 } ) } ` )
341- if ( ( q [ "config.presets" ] ) && ( ( conf . settings . extras ?. features ?. includes ( "metrics.setup.community.presets" ) ) || ( conf . settings . extras ?. features === true ) || ( conf . settings . extras ?. default ) ) ) {
453+ const octokit = { ...api , ...uapi ( req . headers [ "x-metrics-session" ] ) }
454+ let uconf = conf
455+ if ( ( octokit . login ) && ( conf . settings . extras ?. logged ) && ( uconf . settings . extras ?. features !== true ) ) {
456+ console . debug ( `metrics/app/${ login } > session is authenticated, adding additional permissions ${ conf . settings . extras . logged } ` )
457+ uconf = { ...conf , settings :{ ...conf . settings , extras :{ ...conf . settings . extras } } }
458+ uconf . settings . extras . features = uconf . settings . extras . features ?? [ ]
459+ uconf . settings . extras . features . push ( ...conf . settings . extras . logged )
460+ }
461+ //Preset
462+ if ( ( q [ "config.presets" ] ) && ( ( uconf . settings . extras ?. features ?. includes ( "metrics.setup.community.presets" ) ) || ( uconf . settings . extras ?. features === true ) || ( uconf . settings . extras ?. default ) ) ) {
342463 console . debug ( `metrics/app/${ login } > presets have been specified, loading them` )
343464 Object . assign ( q , await presets ( q [ "config.presets" ] ) )
344465 }
345- const convert = conf . settings . outputs . includes ( q [ "config.output" ] ) ? q [ "config.output" ] : conf . settings . outputs [ 0 ]
466+ //Render
467+ const convert = uconf . settings . outputs . includes ( q [ "config.output" ] ) ? q [ "config.output" ] : uconf . settings . outputs [ 0 ]
346468 const { rendered, mime} = await metrics ( { login, q} , {
347- graphql,
348- rest,
469+ ...octokit ,
349470 plugins,
350- conf,
471+ conf : uconf ,
351472 die : q [ "plugins.errors.fatal" ] ?? false ,
352473 verify : q . verify ?? false ,
353474 convert : convert !== "auto" ? convert : null ,
@@ -441,9 +562,11 @@ export default async function({sandbox = false} = {}) {
441562 "── Content ────────────────────────────────────────────────────────" ,
442563 `Plugins enabled │ ${ enabled . map ( ( { name} ) => name ) . join ( ", " ) } ` ,
443564 `Templates enabled │ ${ templates . filter ( ( { enabled} ) => enabled ) . map ( ( { name} ) => name ) . join ( ", " ) } ` ,
565+ "── OAuth ──────────────────────────────────────────────────────────" ,
566+ `Client id │ ${ conf . settings . oauth ?. id ?? "(none)" } ` ,
444567 "── Extras ─────────────────────────────────────────────────────────" ,
445568 `Default │ ${ conf . settings . extras ?. default ?? false } ` ,
446- `Features │ ${ conf . settings . extras ?. features ?? "(none)" } ` ,
569+ `Features │ ${ Array . isArray ( conf . settings . extras ?. features ) ? conf . settings . extras . features ?. length ? conf . settings . extras ?. features : "(none)" : "(default )"} ` ,
447570 "───────────────────────────────────────────────────────────────────" ,
448571 "Server ready !" ,
449572 ] . join ( "\n" ) ) )
0 commit comments