const path = require ( 'node:path' )
const sgit = require ( 'simple-git/promise' )
const fs = require ( 'fs-extra' )
const _ = require ( 'lodash' )
const stream = require ( 'node:stream' )
const util = require ( 'node:util' )
const pipeline = util . promisify ( stream . pipeline )
const klaw = require ( 'klaw' )
const os = require ( 'node:os' )
const pageHelper = require ( '../../../helpers/page' )
const assetHelper = require ( '../../../helpers/asset' )
const commonDisk = require ( '../disk/common' )
module . exports = {
git : null ,
repoPath : path . resolve ( WIKI . ROOTPATH , WIKI . config . dataPath , 'repo' ) ,
async activated ( ) {
// not used
} ,
async deactivated ( ) {
// not used
} ,
/ * *
* INIT
* /
async init ( ) {
WIKI . logger . info ( '(STORAGE/GIT) Initializing...' )
this . repoPath = path . resolve ( WIKI . ROOTPATH , this . config . localRepoPath )
await fs . ensureDir ( this . repoPath )
this . git = sgit ( this . repoPath )
// Set custom binary path
if ( ! _ . isEmpty ( this . config . gitBinaryPath ) ) {
this . git . customBinary ( this . config . gitBinaryPath )
}
// Initialize repo (if needed)
WIKI . logger . info ( '(STORAGE/GIT) Checking repository state...' )
const isRepo = await this . git . checkIsRepo ( )
if ( ! isRepo ) {
WIKI . logger . info ( '(STORAGE/GIT) Initializing local repository...' )
await this . git . init ( )
}
// Disable quotePath
// Link https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
await this . git . raw ( [ 'config' , '--local' , 'core.quotepath' , false ] )
// Set default author
await this . git . raw ( [ 'config' , '--local' , 'user.email' , this . config . defaultEmail ] )
await this . git . raw ( [ 'config' , '--local' , 'user.name' , this . config . defaultName ] )
// Purge existing remotes
WIKI . logger . info ( '(STORAGE/GIT) Listing existing remotes...' )
const remotes = await this . git . getRemotes ( )
if ( remotes . length > 0 ) {
WIKI . logger . info ( '(STORAGE/GIT) Purging existing remotes...' )
for ( let remote of remotes ) {
await this . git . removeRemote ( remote . name )
}
}
// Add remote
WIKI . logger . info ( '(STORAGE/GIT) Setting SSL Verification config...' )
await this . git . raw ( [ 'config' , '--local' , '--bool' , 'http.sslVerify' , _ . toString ( this . config . verifySSL ) ] )
switch ( this . config . authType ) {
case 'ssh' :
WIKI . logger . info ( '(STORAGE/GIT) Setting SSH Command config...' )
if ( this . config . sshPrivateKeyMode === 'contents' ) {
try {
this . config . sshPrivateKeyPath = path . resolve ( WIKI . ROOTPATH , WIKI . config . dataPath , 'secure/git-ssh.pem' )
await fs . outputFile ( this . config . sshPrivateKeyPath , this . config . sshPrivateKeyContent + os . EOL , {
encoding : 'utf8' ,
mode : 0o600
} )
} catch ( err ) {
console . error ( err )
throw err
}
}
await this . git . addConfig ( 'core.sshCommand' , ` ssh -i " ${ this . config . sshPrivateKeyPath } " -o StrictHostKeyChecking=no ` )
WIKI . logger . info ( '(STORAGE/GIT) Adding origin remote via SSH...' )
await this . git . addRemote ( 'origin' , this . config . repoUrl )
break
default :
WIKI . logger . info ( '(STORAGE/GIT) Adding origin remote via HTTP/S...' )
let originUrl = ''
if ( _ . startsWith ( this . config . repoUrl , 'http' ) ) {
originUrl = this . config . repoUrl . replace ( '://' , ` :// ${ encodeURI ( this . config . basicUsername ) } : ${ encodeURI ( this . config . basicPassword ) } @ ` )
} else {
originUrl = ` https:// ${ encodeURI ( this . config . basicUsername ) } : ${ encodeURI ( this . config . basicPassword ) } @ ${ this . config . repoUrl } `
}
await this . git . addRemote ( 'origin' , originUrl )
break
}
// Fetch updates for remote
WIKI . logger . info ( '(STORAGE/GIT) Fetch updates from remote...' )
await this . git . raw ( [ 'remote' , 'update' , 'origin' ] )
// Checkout branch
const branches = await this . git . branch ( )
if ( ! _ . includes ( branches . all , this . config . branch ) && ! _ . includes ( branches . all , ` remotes/origin/ ${ this . config . branch } ` ) ) {
throw new Error ( 'Invalid branch! Make sure it exists on the remote first.' )
}
WIKI . logger . info ( ` (STORAGE/GIT) Checking out branch ${ this . config . branch } ... ` )
await this . git . checkout ( this . config . branch )
// Perform initial sync
await this . sync ( )
WIKI . logger . info ( '(STORAGE/GIT) Initialization completed.' )
} ,
/ * *
* SYNC
* /
async sync ( ) {
const currentCommitLog = _ . get ( await this . git . log ( [ '-n' , '1' , this . config . branch ] ) , 'latest' , { } )
const rootUser = await WIKI . db . users . getRootUser ( )
// Pull rebase
if ( _ . includes ( [ 'sync' , 'pull' ] , this . mode ) ) {
WIKI . logger . info ( ` (STORAGE/GIT) Performing pull rebase from origin on branch ${ this . config . branch } ... ` )
await this . git . pull ( 'origin' , this . config . branch , [ '--rebase' ] )
}
// Push
if ( _ . includes ( [ 'sync' , 'push' ] , this . mode ) ) {
WIKI . logger . info ( ` (STORAGE/GIT) Performing push to origin on branch ${ this . config . branch } ... ` )
let pushOpts = [ '--signed=if-asked' ]
if ( this . mode === 'push' ) {
pushOpts . push ( '--force' )
}
await this . git . push ( 'origin' , this . config . branch , pushOpts )
}
// Process Changes
if ( _ . includes ( [ 'sync' , 'pull' ] , this . mode ) ) {
const latestCommitLog = _ . get ( await this . git . log ( [ '-n' , '1' , this . config . branch ] ) , 'latest' , { } )
const diff = await this . git . diffSummary ( [ '-M' , currentCommitLog . hash , latestCommitLog . hash ] )
if ( _ . get ( diff , 'files' , [ ] ) . length > 0 ) {
let filesToProcess = [ ]
for ( const f of diff . files ) {
const fPath = path . join ( this . repoPath , f . file )
let fStats = { size : 0 }
try {
fStats = await fs . stat ( fPath )
} catch ( err ) {
if ( err . code !== 'ENOENT' ) {
WIKI . logger . warn ( ` (STORAGE/GIT) Failed to access file ${ f . file } ! Skipping... ` )
continue
}
}
filesToProcess . push ( {
... f ,
file : {
path : fPath ,
stats : fStats
} ,
relPath : f . file
} )
}
await this . processFiles ( filesToProcess , rootUser )
}
}
} ,
/ * *
* Process Files
*
* @ param { Array < String > } files Array of files to process
* /
async processFiles ( files , user ) {
for ( const item of files ) {
const contentType = pageHelper . getContentType ( item . relPath )
const fileExists = await fs . pathExists ( item . file )
if ( ! item . binary && contentType ) {
// -> Page
if ( ! fileExists && item . deletions > 0 && item . insertions === 0 ) {
// Page was deleted by git, can safely mark as deleted in DB
WIKI . logger . info ( ` (STORAGE/GIT) Page marked as deleted: ${ item . relPath } ` )
const contentPath = pageHelper . getPagePath ( item . relPath )
await WIKI . db . pages . deletePage ( {
user : user ,
path : contentPath . path ,
locale : contentPath . locale ,
skipStorage : true
} )
continue
}
try {
await commonDisk . processPage ( {
user ,
relPath : item . relPath ,
fullPath : this . repoPath ,
contentType : contentType ,
moduleName : 'GIT'
} )
} catch ( err ) {
WIKI . logger . warn ( ` (STORAGE/GIT) Failed to process ${ item . relPath } ` )
WIKI . logger . warn ( err )
}
} else {
// -> Asset
if ( ! fileExists && ( ( item . before > 0 && item . after === 0 ) || ( item . deletions > 0 && item . insertions === 0 ) ) ) {
// Asset was deleted by git, can safely mark as deleted in DB
WIKI . logger . info ( ` (STORAGE/GIT) Asset marked as deleted: ${ item . relPath } ` )
const fileHash = assetHelper . generateHash ( item . relPath )
const assetToDelete = await WIKI . db . assets . query ( ) . findOne ( { hash : fileHash } )
if ( assetToDelete ) {
await WIKI . db . knex ( 'assetData' ) . where ( 'id' , assetToDelete . id ) . del ( )
await WIKI . db . assets . query ( ) . deleteById ( assetToDelete . id )
await assetToDelete . deleteAssetCache ( )
} else {
WIKI . logger . info ( ` (STORAGE/GIT) Asset was not found in the DB, nothing to delete: ${ item . relPath } ` )
}
continue
}
try {
await commonDisk . processAsset ( {
user ,
relPath : item . relPath ,
file : item . file ,
contentType : contentType ,
moduleName : 'GIT'
} )
} catch ( err ) {
WIKI . logger . warn ( ` (STORAGE/GIT) Failed to process asset ${ item . relPath } ` )
WIKI . logger . warn ( err )
}
}
}
} ,
/ * *
* CREATE
*
* @ param { Object } page Page to create
* /
async created ( page ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing new file [ ${ page . localeCode } ] ${ page . path } ... ` )
let fileName = ` ${ page . path } . ${ pageHelper . getFileExtension ( page . contentType ) } `
if ( WIKI . config . lang . namespacing && WIKI . config . lang . code !== page . localeCode ) {
fileName = ` ${ page . localeCode } / ${ fileName } `
}
const filePath = path . join ( this . repoPath , fileName )
await fs . outputFile ( filePath , page . injectMetadata ( ) , 'utf8' )
const gitFilePath = ` ./ ${ fileName } `
if ( ( await this . git . checkIgnore ( gitFilePath ) ) . length === 0 ) {
await this . git . add ( gitFilePath )
await this . git . commit ( ` docs: create ${ page . path } ` , fileName , {
'--author' : ` " ${ page . authorName } < ${ page . authorEmail } >" `
} )
}
} ,
/ * *
* UPDATE
*
* @ param { Object } page Page to update
* /
async updated ( page ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing updated file [ ${ page . localeCode } ] ${ page . path } ... ` )
let fileName = ` ${ page . path } . ${ pageHelper . getFileExtension ( page . contentType ) } `
if ( WIKI . config . lang . namespacing && WIKI . config . lang . code !== page . localeCode ) {
fileName = ` ${ page . localeCode } / ${ fileName } `
}
const filePath = path . join ( this . repoPath , fileName )
await fs . outputFile ( filePath , page . injectMetadata ( ) , 'utf8' )
const gitFilePath = ` ./ ${ fileName } `
if ( ( await this . git . checkIgnore ( gitFilePath ) ) . length === 0 ) {
await this . git . add ( gitFilePath )
await this . git . commit ( ` docs: update ${ page . path } ` , fileName , {
'--author' : ` " ${ page . authorName } < ${ page . authorEmail } >" `
} )
}
} ,
/ * *
* DELETE
*
* @ param { Object } page Page to delete
* /
async deleted ( page ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing removed file [ ${ page . localeCode } ] ${ page . path } ... ` )
let fileName = ` ${ page . path } . ${ pageHelper . getFileExtension ( page . contentType ) } `
if ( WIKI . config . lang . namespacing && WIKI . config . lang . code !== page . localeCode ) {
fileName = ` ${ page . localeCode } / ${ fileName } `
}
const gitFilePath = ` ./ ${ fileName } `
if ( ( await this . git . checkIgnore ( gitFilePath ) ) . length === 0 ) {
await this . git . rm ( gitFilePath )
await this . git . commit ( ` docs: delete ${ page . path } ` , fileName , {
'--author' : ` " ${ page . authorName } < ${ page . authorEmail } >" `
} )
}
} ,
/ * *
* RENAME
*
* @ param { Object } page Page to rename
* /
async renamed ( page ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing file move from [ ${ page . localeCode } ] ${ page . path } to [ ${ page . destinationLocaleCode } ] ${ page . destinationPath } ... ` )
let sourceFileName = ` ${ page . path } . ${ pageHelper . getFileExtension ( page . contentType ) } `
let destinationFileName = ` ${ page . destinationPath } . ${ pageHelper . getFileExtension ( page . contentType ) } `
if ( WIKI . config . lang . namespacing ) {
if ( WIKI . config . lang . code !== page . localeCode ) {
sourceFileName = ` ${ page . localeCode } / ${ sourceFileName } `
}
if ( WIKI . config . lang . code !== page . destinationLocaleCode ) {
destinationFileName = ` ${ page . destinationLocaleCode } / ${ destinationFileName } `
}
}
const sourceFilePath = path . join ( this . repoPath , sourceFileName )
const destinationFilePath = path . join ( this . repoPath , destinationFileName )
await fs . move ( sourceFilePath , destinationFilePath )
await this . git . rm ( ` ./ ${ sourceFileName } ` )
await this . git . add ( ` ./ ${ destinationFileName } ` )
await this . git . commit ( ` docs: rename ${ page . path } to ${ page . destinationPath } ` , [ sourceFilePath , destinationFilePath ] , {
'--author' : ` " ${ page . moveAuthorName } < ${ page . moveAuthorEmail } >" `
} )
} ,
/ * *
* ASSET UPLOAD
*
* @ param { Object } asset Asset to upload
* /
async assetUploaded ( asset ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing new file ${ asset . path } ... ` )
const filePath = path . join ( this . repoPath , asset . path )
await fs . outputFile ( filePath , asset . data , 'utf8' )
await this . git . add ( ` ./ ${ asset . path } ` )
await this . git . commit ( ` docs: upload ${ asset . path } ` , asset . path , {
'--author' : ` " ${ asset . authorName } < ${ asset . authorEmail } >" `
} )
} ,
/ * *
* ASSET DELETE
*
* @ param { Object } asset Asset to upload
* /
async assetDeleted ( asset ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing removed file ${ asset . path } ... ` )
await this . git . rm ( ` ./ ${ asset . path } ` )
await this . git . commit ( ` docs: delete ${ asset . path } ` , asset . path , {
'--author' : ` " ${ asset . authorName } < ${ asset . authorEmail } >" `
} )
} ,
/ * *
* ASSET RENAME
*
* @ param { Object } asset Asset to upload
* /
async assetRenamed ( asset ) {
WIKI . logger . info ( ` (STORAGE/GIT) Committing file move from ${ asset . path } to ${ asset . destinationPath } ... ` )
await this . git . mv ( ` ./ ${ asset . path } ` , ` ./ ${ asset . destinationPath } ` )
await this . git . commit ( ` docs: rename ${ asset . path } to ${ asset . destinationPath } ` , [ asset . path , asset . destinationPath ] , {
'--author' : ` " ${ asset . moveAuthorName } < ${ asset . moveAuthorEmail } >" `
} )
} ,
async getLocalLocation ( asset ) {
return path . join ( this . repoPath , asset . path )
} ,
/ * *
* HANDLERS
* /
async importAll ( ) {
WIKI . logger . info ( ` (STORAGE/GIT) Importing all content from local Git repo to the DB... ` )
const rootUser = await WIKI . db . users . getRootUser ( )
await pipeline (
klaw ( this . repoPath , {
filter : ( f ) => {
return ! _ . includes ( f , '.git' )
}
} ) ,
new stream . Transform ( {
objectMode : true ,
transform : async ( file , enc , cb ) => {
const relPath = file . path . substr ( this . repoPath . length + 1 )
if ( file . stats . size < 1 ) {
// Skip directories and zero-byte files
return cb ( )
} else if ( relPath && relPath . length > 3 ) {
WIKI . logger . info ( ` (STORAGE/GIT) Processing ${ relPath } ... ` )
await this . processFiles ( [ {
user : rootUser ,
relPath ,
file ,
deletions : 0 ,
insertions : 0
} ] , rootUser )
}
cb ( )
}
} )
)
commonDisk . clearFolderCache ( )
WIKI . logger . info ( '(STORAGE/GIT) Import completed.' )
} ,
async syncUntracked ( ) {
WIKI . logger . info ( ` (STORAGE/GIT) Adding all untracked content... ` )
// -> Pages
await pipeline (
WIKI . db . knex . column ( 'path' , 'localeCode' , 'title' , 'description' , 'contentType' , 'content' , 'isPublished' , 'updatedAt' , 'createdAt' ) . select ( ) . from ( 'pages' ) . where ( {
isPrivate : false
} ) . stream ( ) ,
new stream . Transform ( {
objectMode : true ,
transform : async ( page , enc , cb ) => {
let fileName = ` ${ page . path } . ${ pageHelper . getFileExtension ( page . contentType ) } `
if ( WIKI . config . lang . namespacing && WIKI . config . lang . code !== page . localeCode ) {
fileName = ` ${ page . localeCode } / ${ fileName } `
}
WIKI . logger . info ( ` (STORAGE/GIT) Adding page ${ fileName } ... ` )
const filePath = path . join ( this . repoPath , fileName )
await fs . outputFile ( filePath , pageHelper . injectPageMetadata ( page ) , 'utf8' )
await this . git . add ( ` ./ ${ fileName } ` )
cb ( )
}
} )
)
// -> Assets
const assetFolders = await WIKI . db . assetFolders . getAllPaths ( )
await pipeline (
WIKI . db . knex . column ( 'filename' , 'folderId' , 'data' ) . select ( ) . from ( 'assets' ) . join ( 'assetData' , 'assets.id' , '=' , 'assetData.id' ) . stream ( ) ,
new stream . Transform ( {
objectMode : true ,
transform : async ( asset , enc , cb ) => {
const filename = ( asset . folderId && asset . folderId > 0 ) ? ` ${ _ . get ( assetFolders , asset . folderId ) } / ${ asset . filename } ` : asset . filename
WIKI . logger . info ( ` (STORAGE/GIT) Adding asset ${ filename } ... ` )
await fs . outputFile ( path . join ( this . repoPath , filename ) , asset . data )
await this . git . add ( ` ./ ${ filename } ` )
cb ( )
}
} )
)
await this . git . commit ( ` docs: add all untracked content ` )
WIKI . logger . info ( '(STORAGE/GIT) All content is now tracked.' )
} ,
async purge ( ) {
WIKI . logger . info ( ` (STORAGE/GIT) Purging local repository... ` )
await fs . emptyDir ( this . repoPath )
WIKI . logger . info ( '(STORAGE/GIT) Local repository is now empty. Reinitializing...' )
await this . init ( )
}
}