import { Signal , ISignal } from '@lumino/signaling' ; import { PathExt , URLExt } from '@jupyterlab/coreutils' ; import { DocumentRegistry } from '@jupyterlab/docregistry' ; import { ObservableValue } from '@jupyterlab/observables' ; import { Contents , ServerConnection } from '@jupyterlab/services' ; import { browserApiRequest , proxiedApiRequest , GitHubRepo , GitHubContents , GitHubBlob , GitHubFileContents , GitHubDirectoryListing } from './github' ; import * as base64js from 'base64-js' ; export const DEFAULT_GITHUB_API_URL = 'https://api.github.com' ; export const DEFAULT_GITHUB_BASE_URL = 'https://github.com' ; /**
 * A Contents.IDrive implementation that serves as a read-only
 * view onto GitHub repositories.
 */ export class GitHubDrive implements Contents . IDrive { param options constructor ( registry ) { this . _serverSettings = ServerConnection . makeSettings ( ) ; this . _fileTypeForPath = ( path ) => { const types = registry . getFileTypesForPath ( path ) ; return types . length === 0 ? registry . getFileType ( 'text' ) ! : types [ 0 ] ; } ; this . baseUrl = DEFAULT_GITHUB_BASE_URL ; this . _useProxy = new Promise < boolean > ( resolve => { const requestUrl = URLExt . join ( this . _serverSettings . baseUrl , 'github' ) ; proxiedApiRequest < any > ( requestUrl , this . _serverSettings ) . then ( ( ) => { resolve ( true ) ; } ) . catch ( ( ) => { console . warn ( 'The JupyterLab GitHub server extension appears ' + 'to be missing. If you do not install it with application ' + 'credentials, you are likely to be rate limited by GitHub ' + 'very quickly' ) ; resolve ( false ) ; } ) ; } ) ; this . rateLimitedState = new ObservableValue ( false ) ; } /**
     * The name of the drive.
     */ get name ( ) : 'GitHub' { return 'GitHub' ; } /**
     * State for whether the user is valid.
     */ get validUser ( ) : boolean { return this . _validUser ; } /**
     * Settings for the notebook server.
     */ get serverSettings ( ) : ServerConnection . ISettings { return this . _serverSettings ; } /**
     * State for whether the drive is being rate limited by GitHub.
     */ readonly rateLimitedState : ObservableValue ; /**
     * A signal emitted when a file operation takes place.
     */ get fileChanged ( ) : ISignal < this , Contents . IChangedArgs > { return this . _fileChanged ; } /**
     * Test whether the manager has been disposed.
     */ get isDisposed ( ) : boolean { return this . _isDisposed ; } /**
     * Dispose of the resources held by the manager.
     */ dispose ( ) { if ( this . isDisposed ) { return ; } this . _isDisposed = true ; Signal . clearData ( this ) ; } /**
     * The GitHub base URL
     */ get baseUrl ( ) : string { return this . _baseUrl ; } /**
     * The GitHub base URL is set by the settingsRegistry change hook
     */ set baseUrl ( url ) { this . _baseUrl = url ; } /**
     * The GitHub access token
     */ get accessToken ( ) : string | null | undefined { return this . _accessToken ; } /**
     * The GitHub access token is set by the settingsRegistry change hook
     */ set accessToken ( token ) { this . _accessToken = token ; } param path param options returns get ( path , options ? ) { const resource = parsePath ( path ) ; if ( resource . user === '' ) { this . _validUser = false ; return Promise . resolve ( Private . dummyDirectory ) ; } if ( resource . user && ! resource . repository ) { return this . _listRepos ( resource . user ) ; } const apiPath = URLExt . encodeParts ( URLExt . join ( 'repos' , resource . user , resource . repository , 'contents' , resource . path ) ) ; return this . _apiRequest < GitHubContents > ( apiPath ) . then ( contents => { this . _validUser = true ; if ( this . rateLimitedState . get ( ) !== false ) { this . rateLimitedState . set ( false ) ; } return Private . gitHubContentsToJupyterContents ( path , contents , this . _fileTypeForPath ) ; } ) . catch ( ( err ) => { if ( err . response . status === 404 ) { console . warn ( 'GitHub: cannot find org/repo. ' + 'Perhaps you misspelled something?' ) ; this . _validUser = false ; return Private . dummyDirectory ; } else if ( err . response . status === 403 && err . message . indexOf ( 'rate limit' ) !== - 1 ) { if ( this . rateLimitedState . get ( ) !== true ) { this . rateLimitedState . set ( true ) ; } console . error ( err . message ) ; return Promise . reject ( err ) ; } else if ( err . response . status === 403 && err . message . indexOf ( 'blob' ) !== - 1 ) { this . _validUser = true ; if ( this . rateLimitedState . get ( ) !== false ) { this . rateLimitedState . set ( false ) ; } return this . _getBlob ( path ) ; } else { console . error ( err . message ) ; return Promise . reject ( err ) ; } } ) ; } param path getDownloadUrl ( path ) { const resource = parsePath ( path ) ; if ( ! resource . user ) { return Promise . reject ( 'GitHub: no active organization' ) ; } if ( ! resource . path ) { return Promise . reject ( 'GitHub: No file selected' ) ; } const dirname = PathExt . dirname ( resource . path ) ; const dirApiPath = URLExt . encodeParts ( URLExt . join ( 'repos' , resource . user , resource . repository , 'contents' , dirname ) ) ; return this . _apiRequest < GitHubDirectoryListing > ( dirApiPath ) . then ( dirContents => { for ( let item of dirContents ) { if ( item . path === resource . path ) { return item . download_url ; } } throw Private . makeError ( 404 , `Cannot find file at ${ resource . path }` ) ; } ) ; } param options returns newUntitled ( options = { } ) { return Promise . reject ( 'Repository is read only' ) ; } param path returns delete ( path ) { return Promise . reject ( 'Repository is read only' ) ; } param path param newPath returns rename ( path , newPath ) { return Promise . reject ( 'Repository is read only' ) ; } param path param options returns save ( path , options ) { return Promise . reject ( 'Repository is read only' ) ; } param path param toDir returns copy ( fromFile , toDir ) { return Promise . reject ( 'Repository is read only' ) ; } param path returns createCheckpoint ( path ) { return Promise . reject ( 'Repository is read only' ) ; } param path returns listCheckpoints ( path ) { return Promise . resolve ( [ ] ) ; } param path param checkpointID returns restoreCheckpoint ( path , checkpointID ) { return Promise . reject ( 'Repository is read only' ) ; } param path param checkpointID returns deleteCheckpoint ( path , checkpointID ) { return Promise . reject ( 'Read only' ) ; } /**
     * If a file is too large (> 1Mb), we need to access it over the
     * GitHub Git Data API.
     */ private _getBlob ( path ) { let blobData ; const resource = parsePath ( path ) ; const dirname = PathExt . dirname ( resource . path ) ; const dirApiPath = URLExt . encodeParts ( URLExt . join ( 'repos' , resource . user , resource . repository , 'contents' , dirname ) ) ; return this . _apiRequest < GitHubDirectoryListing > ( dirApiPath ) . then ( dirContents => { for ( let item of dirContents ) { if ( item . path === resource . path ) { blobData = item as GitHubFileContents ; return item . sha ; } } throw Error ( 'Cannot find sha for blob' ) ; } ) . then ( sha => { const blobApiPath = URLExt . encodeParts ( URLExt . join ( 'repos' , resource . user , resource . repository , 'git' , 'blobs' , sha ) ) ; return this . _apiRequest < GitHubBlob > ( blobApiPath ) ; } ) . then ( blob => { blobData . content = blob . content ; return Private . gitHubContentsToJupyterContents ( path , blobData , this . _fileTypeForPath ) ; } ) ; } /**
     * List the repositories for the currently active user.
     */ private _listRepos ( user ) { const apiPath = URLExt . encodeParts ( URLExt . join ( 'orgs' , user , 'repos' ) ) ; return this . _apiRequest < GitHubRepo [ ] > ( apiPath ) . catch ( err => { if ( err . response . status === 404 ) { return this . _apiRequest < any > ( 'user' ) . then ( currentUser => { let reposPath ; if ( currentUser . login === user ) { reposPath = 'user/repos?type=owner' ; } else { reposPath = URLExt . encodeParts ( URLExt . join ( 'users' , user , 'repos' ) ) ; } return this . _apiRequest < GitHubRepo [ ] > ( reposPath ) ; } ) . catch ( err => { if ( err . response . status === 401 ) { const reposPath = URLExt . encodeParts ( URLExt . join ( 'users' , user , 'repos' ) ) ; return this . _apiRequest < GitHubRepo [ ] > ( reposPath ) ; } throw err ; } ) ; } throw err ; } ) . then ( repos => { this . _validUser = true ; if ( this . rateLimitedState . get ( ) !== false ) { this . rateLimitedState . set ( false ) ; } return Private . reposToDirectory ( repos ) ; } ) . catch ( err => { if ( err . response . status === 403 && err . message . indexOf ( 'rate limit' ) !== - 1 ) { if ( this . rateLimitedState . get ( ) !== true ) { this . rateLimitedState . set ( true ) ; } } else { console . error ( err . message ) ; console . warn ( 'GitHub: cannot find user. ' + 'Perhaps you misspelled something?' ) ; this . _validUser = false ; } return Private . dummyDirectory ; } ) ; } /**
     * Determine whether to make the call via the
     * notebook server proxy or not.
     */ private _apiRequest < T > ( apiPath ) { return this . _useProxy . then ( result => { let parts = apiPath . split ( '?' ) ; let path = parts [ 0 ] ; let query = ( parts [ 1 ] || '' ) . split ( '&' ) ; let params = { } ; for ( const param of query ) { if ( param ) { let [ key , value ] = param . split ( '=' ) ; params [ key ] = value ; } } let requestUrl ; if ( result === true ) { requestUrl = URLExt . join ( this . _serverSettings . baseUrl , 'github' ) ; if ( this . accessToken ) { params [ 'access_token' ] = this . accessToken ; } } else { requestUrl = DEFAULT_GITHUB_API_URL ; } if ( path ) { requestUrl = URLExt . join ( requestUrl , path ) ; } let newQuery = Object . keys ( params ) . map ( key => `${ key }=${ params [ key ] }` ) . join ( '&' ) ; requestUrl += '?' + newQuery ; if ( result === true ) { return proxiedApiRequest < T > ( requestUrl , this . _serverSettings ) ; } else { return browserApiRequest < T > ( requestUrl ) ; } } ) ; } private _baseUrl : string = 'github' ; private _accessToken : string | null | undefined ; private _validUser = false ; private _serverSettings : ServerConnection . ISettings ; private _useProxy : Promise < boolean > ; private _fileTypeForPath : ( path : string ) => DocumentRegistry . IFileType ; private _isDisposed = false ; private _fileChanged = new Signal < this , Contents . IChangedArgs > ( this ) ; } /**
 * Specification for a file in a repository.
 */ export interface IGitHubResource { /**
     * The user or organization for the resource.
     */ readonly user : string ; /**
     * The repository in the organization/user.
     */ readonly repository : string ; /**
     * The path in the repository to the resource.
     */ readonly path : string ; } /**
 * Parse a path into a IGitHubResource.
 */ export function parsePath ( path ) { const parts = path . split ( '/' ) ; const user = parts . length > 0 ? parts [ 0 ] : '' ; const repository = parts . length > 1 ? parts [ 1 ] : '' ; const repoPath = parts . length > 2 ? URLExt . join ( ... parts . slice ( 2 ) ) : '' ; return { user , repository , path : repoPath } ; } /**
 * Private namespace for utility functions.
 */ namespace Private { /**
     * A dummy contents model indicating an invalid or
     * nonexistent repository.
     */ export const dummyDirectory = { type : 'directory' , path : '' , name : '' , format : 'json' , content : [ ] , created : '' , writable : false , last_modified : '' , mimetype : '' } ; param path param contents param fileTypeForPath returns export function gitHubContentsToJupyterContents ( path , contents , fileTypeForPath ) { if ( Array . isArray ( contents ) ) { return { name : PathExt . basename ( path ) , path : path , format : 'json' , type : 'directory' , writable : false , created : '' , last_modified : '' , mimetype : '' , content : contents . map ( c => { return gitHubContentsToJupyterContents ( PathExt . join ( path , c . name ) , c , fileTypeForPath ) ; } ) } as Contents . IModel ; } else if ( contents . type === 'file' || contents . type === 'symlink' ) { const fileType = fileTypeForPath ( path ) ; const fileContents = ( contents as GitHubFileContents ) . content ; let content ; switch ( fileType . fileFormat ) { case 'text' : content = fileContents !== undefined ? Private . b64DecodeUTF8 ( fileContents ) : null ; break ; case 'base64' : content = fileContents !== undefined ? fileContents : null ; break ; case 'json' : content = fileContents !== undefined ? JSON . parse ( Private . b64DecodeUTF8 ( fileContents ) ) : null ; break ; default : throw new Error ( `Unexpected file format: ${ fileType . fileFormat }` ) ; } return { name : PathExt . basename ( path ) , path : path , format : fileType . fileFormat , type : 'file' , created : '' , writable : false , last_modified : '' , mimetype : fileType . mimeTypes [ 0 ] , content } ; } else if ( contents . type === 'dir' ) { return { name : PathExt . basename ( path ) , path : path , format : 'json' , type : 'directory' , created : '' , writable : false , last_modified : '' , mimetype : '' , content : null } ; } else if ( contents . type === 'submodule' ) { throw makeError ( 400 , `Cannot open "${ contents . name }" because it is a submodule` ) ; } else { throw makeError ( 500 , `"${ contents . name }" has and unexpected type: ${ contents . type }` ) ; } } param repo returns export function reposToDirectory ( repos ) { let content = repos . map ( repo => { return { name : repo . name , path : repo . full_name , format : 'json' , type : 'directory' , created : '' , writable : false , last_modified : '' , mimetype : '' , content : null } as Contents . IModel ; } ) ; return { name : '' , path : '' , format : 'json' , type : 'directory' , created : '' , last_modified : '' , writable : false , mimetype : '' , content } ; } /**
     * Wrap an API error in a hacked-together error object
     * masquerading as an `ServerConnection.ResponseError`.
     */ export function makeError ( code , message ) { const response = new Response ( message , { status : code , statusText : message } ) ; return new ServerConnection . ResponseError ( response , message ) ; } /**
     * Decoder from bytes to UTF-8.
     */ const decoder = new TextDecoder ( 'utf8' ) ; /**
     * Decode a base-64 encoded string into unicode.
     *
     * See https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#Solution_2_%E2%80%93_rewrite_the_DOMs_atob()_and_btoa()_using_JavaScript's_TypedArrays_and_UTF-8
     */ export function b64DecodeUTF8 ( str ) { const bytes = base64js . toByteArray ( str . replace ( /\n/g , '' ) ) ; return decoder . decode ( bytes ) ; } }