/**
Copyright 2021 Forestry.io Holdings, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
    http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/ import { AUTH_TOKEN_KEY , TokenObject , authenticate } from '../auth/authenticate' ; import { BranchChangeEvent , BranchData , EventBus } from '@tinacms/toolkit' ; import { DocumentNode , GraphQLSchema , buildClientSchema , getIntrospectionQuery , print , } from 'graphql' ; import { formify } from './formify' ; import gql from 'graphql-tag' ; export type TinaIOConfig = { frontendUrlOverride ? : string ; identityApiUrlOverride ? : string ; contentApiUrlOverride ? : string ; } ; interface ServerOptions { clientId : string ; branch : string ; customContentApiUrl ? : string ; getTokenFn ? : ( ) => Promise < TokenObject > ; tinaioConfig ? : TinaIOConfig ; tokenStorage ? : 'MEMORY' | 'LOCAL_STORAGE' | 'CUSTOM' ; } const captureBranchName = /^refs\/heads\/(.*)/ ; const parseRefForBranchName = ( ref ) => { const matches = ref . match ( captureBranchName ) ; return matches [ 1 ] ; } ; export class Client { frontendUrl : string ; contentApiUrl : string ; identityApiUrl : string ; schema : GraphQLSchema ; clientId : string ; contentApiBase : string ; query : string ; setToken : ( _token : TokenObject ) => void ; private getToken : ( ) => Promise < TokenObject > ; private token : string ; private branch : string ; private options : ServerOptions ; events = new EventBus ( ) ; constructor ( { tokenStorage = 'MEMORY' , ... options } ) { this . options = options ; this . setBranch ( options . branch ) ; this . events . subscribe < BranchChangeEvent > ( 'branch:change' , ( { branchName } ) => { this . setBranch ( branchName ) ; } ) ; this . clientId = options . clientId ; switch ( tokenStorage ) { case 'LOCAL_STORAGE' : this . getToken = async function ( ) { const tokens = localStorage . getItem ( AUTH_TOKEN_KEY ) || null ; if ( tokens ) { return await this . getRefreshedToken ( tokens ) ; } else { return { access_token : null , id_token : null , refresh_token : null , } ; } } ; this . setToken = function ( token ) { localStorage . setItem ( AUTH_TOKEN_KEY , JSON . stringify ( token , null , 2 ) ) ; } ; break ; case 'MEMORY' : this . getToken = async ( ) => { if ( this . token ) { return await this . getRefreshedToken ( this . token ) ; } else { return { access_token : null , id_token : null , refresh_token : null , } ; } } ; this . setToken = ( token ) => { this . token = JSON . stringify ( token , null , 2 ) ; } ; break ; case 'CUSTOM' : if ( ! options . getTokenFn ) { throw new Error ( 'When CUSTOM token storage is selected, a getTokenFn must be provided' ) ; } this . getToken = options . getTokenFn ; break ; } } public get isLocalMode ( ) { return this . contentApiUrl . includes ( 'localhost' ) ; } setBranch ( branchName ) { const encodedBranch = encodeURIComponent ( branchName ) ; this . frontendUrl = this . options . tinaioConfig ?. frontendUrlOverride || 'https://app.tina.io' ; this . identityApiUrl = this . options . tinaioConfig ?. identityApiUrlOverride || 'https://identity.tinajs.io' ; this . contentApiBase = this . options . tinaioConfig ?. contentApiUrlOverride || `https://content.tinajs.io` ; this . contentApiUrl = this . options . customContentApiUrl || `${ this . contentApiBase }/content/${ this . options . clientId }/github/${ encodedBranch }` ; } addPendingContent = async ( props ) => { const mutation = `#graphql
mutation addPendingDocumentMutation(
  $relativePath: String!
  $collection: String!
  $template: String
) {
  addPendingDocument(
    relativePath: $relativePath
    template: $template
    collection: $collection
  ) {
    ... on Document {
      sys {
        relativePath
        path
        breadcrumbs
        collection {
          slug
        }
      }
    }
  }
}` ; const result = await this . request ( mutation , { variables : props , } ) ; return result ; } ; getSchema = async ( ) => { if ( ! this . schema ) { const data = await this . request < any > ( getIntrospectionQuery ( ) , { variables : { } , } ) ; this . schema = buildClientSchema ( data ) ; } return this . schema ; } ; async requestWithForm < ReturnType > ( query , { variables } ) { const schema = await this . getSchema ( ) ; const formifiedQuery = formify ( query ( gql ) , schema ) ; return this . request < ReturnType > ( print ( formifiedQuery ) , { variables } ) ; } async request < ReturnType > ( query , { variables } ) { const res = await fetch ( this . contentApiUrl , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , Authorization : 'Bearer ' + ( await this . getToken ( ) ) . id_token , } , body : JSON . stringify ( { query : typeof query === 'function' ? print ( query ( gql ) ) : query , variables , } ) , } ) ; if ( res . status !== 200 ) { throw new Error ( `Unable to complete request, ${ res . statusText }` ) ; } const json = await res . json ( ) ; if ( json . errors ) { throw new Error ( `Unable to fetch, errors: \n\t${ json . errors . map ( ( error ) => error . message ) . join ( '\n' ) }` ) ; return json ; } return json . data as ReturnType ; } parseJwt ( token ) { const base64Url = token . split ( '.' ) [ 1 ] ; const base64 = base64Url . replace ( /-/g , '+' ) . replace ( /_/g , '/' ) ; const jsonPayload = decodeURIComponent ( atob ( base64 ) . split ( '' ) . map ( function ( c ) { return '%' + ( '00' + c . charCodeAt ( 0 ) . toString ( 16 ) ) . slice ( - 2 ) ; } ) . join ( '' ) ) ; return JSON . parse ( jsonPayload ) ; } async getRefreshedToken ( tokens ) { const { access_token , id_token , refresh_token } = JSON . parse ( tokens ) ; const { exp , iss , client_id } = this . parseJwt ( access_token ) ; if ( Date . now ( ) / 1000 >= exp ) { const refreshResponse = await fetch ( iss , { method : 'POST' , headers : { 'Content-Type' : 'application/x-amz-json-1.1' , 'x-amz-target' : 'AWSCognitoIdentityProviderService.InitiateAuth' , } , body : JSON . stringify ( { ClientId : client_id , AuthFlow : 'REFRESH_TOKEN_AUTH' , AuthParameters : { REFRESH_TOKEN : refresh_token , DEVICE_KEY : null , } , } ) , } ) ; if ( refreshResponse . status !== 200 ) { throw new Error ( 'Unable to refresh auth tokens' ) ; } const responseJson = await refreshResponse . json ( ) ; const newToken = { access_token : responseJson . AuthenticationResult . AccessToken , id_token : responseJson . AuthenticationResult . IdToken , refresh_token , } ; this . setToken ( newToken ) ; return Promise . resolve ( newToken ) ; } return Promise . resolve ( { access_token , id_token , refresh_token } ) ; } async isAuthorized ( ) { return this . isAuthenticated ( ) ; } async isAuthenticated ( ) { return ! ! ( await this . getUser ( ) ) ; } async authenticate ( ) { const token = await authenticate ( this . clientId , this . frontendUrl ) ; this . setToken ( token ) ; return token ; } example param input param init async fetchWithToken ( input , init ? ) { const headers = init ?. headers || { } ; return await fetch ( input , { ... init , headers : new Headers ( { Authorization : 'Bearer ' + ( await this . getToken ( ) ) . id_token , ... headers , } ) , } ) ; } async getUser ( ) { if ( ! this . clientId ) { return null ; } const url = `${ this . identityApiUrl }/v2/apps/${ this . clientId }/currentUser` ; try { const res = await this . fetchWithToken ( url , { method : 'GET' , } ) ; const val = await res . json ( ) ; if ( ! res . status . toString ( ) . startsWith ( '2' ) ) { console . error ( val . error ) ; return null ; } return val ; } catch ( e ) { console . error ( e ) ; return null ; } } async listBranches ( ) { const url = `${ this . contentApiBase }/github/${ this . clientId }/list_branches` ; const res = await this . fetchWithToken ( url , { method : 'GET' , } ) ; return res . json ( ) ; } async createBranch ( { baseBranch , branchName } ) { const url = `${ this . contentApiBase }/github/${ this . clientId }/create_branch` ; try { const res = await this . fetchWithToken ( url , { method : 'POST' , body : JSON . stringify ( { baseBranch , branchName , } ) , headers : { 'Content-Type' : 'application/json' , } , } ) ; return await res . json ( ) . then ( ( r ) => parseRefForBranchName ( r . data . ref ) ) ; } catch ( error ) { console . error ( 'There was an error creating a new branch.' , error ) ; return null ; } } } export const DEFAULT_LOCAL_TINA_GQL_SERVER_URL = 'http://localhost:4001/graphql' ; export class LocalClient extends Client { constructor ( props ? ) { const clientProps = { clientId : '' , branch : '' , customContentApiUrl : props && props . customContentApiUrl ? props . customContentApiUrl : DEFAULT_LOCAL_TINA_GQL_SERVER_URL , } ; super ( clientProps ) ; } async isAuthorized ( ) { return true ; } async isAuthenticated ( ) { return true ; } }