storage.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. const path = require('path')
  2. const sgit = require('simple-git/promise')
  3. const fs = require('fs-extra')
  4. const _ = require('lodash')
  5. const stream = require('stream')
  6. const Promise = require('bluebird')
  7. const pipeline = Promise.promisify(stream.pipeline)
  8. const klaw = require('klaw')
  9. const os = require('os')
  10. const pageHelper = require('../../../helpers/page')
  11. const assetHelper = require('../../../helpers/asset')
  12. const commonDisk = require('../disk/common')
  13. /* global WIKI */
  14. module.exports = {
  15. git: null,
  16. repoPath: path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'repo'),
  17. async activated() {
  18. // not used
  19. },
  20. async deactivated() {
  21. // not used
  22. },
  23. /**
  24. * INIT
  25. */
  26. async init() {
  27. WIKI.logger.info('(STORAGE/GIT) Initializing...')
  28. this.repoPath = path.resolve(WIKI.ROOTPATH, this.config.localRepoPath)
  29. await fs.ensureDir(this.repoPath)
  30. this.git = sgit(this.repoPath)
  31. // Set custom binary path
  32. if (!_.isEmpty(this.config.gitBinaryPath)) {
  33. this.git.customBinary(this.config.gitBinaryPath)
  34. }
  35. // Initialize repo (if needed)
  36. WIKI.logger.info('(STORAGE/GIT) Checking repository state...')
  37. const isRepo = await this.git.checkIsRepo()
  38. if (!isRepo) {
  39. WIKI.logger.info('(STORAGE/GIT) Initializing local repository...')
  40. await this.git.init()
  41. }
  42. // Disable quotePath, color output
  43. // Link https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
  44. await this.git.raw(['config', '--local', 'core.quotepath', false])
  45. await this.git.raw(['config', '--local', 'color.ui', false])
  46. // Set default author
  47. await this.git.raw(['config', '--local', 'user.email', this.config.defaultEmail])
  48. await this.git.raw(['config', '--local', 'user.name', this.config.defaultName])
  49. // Purge existing remotes
  50. WIKI.logger.info('(STORAGE/GIT) Listing existing remotes...')
  51. const remotes = await this.git.getRemotes()
  52. if (remotes.length > 0) {
  53. WIKI.logger.info('(STORAGE/GIT) Purging existing remotes...')
  54. for (let remote of remotes) {
  55. await this.git.removeRemote(remote.name)
  56. }
  57. }
  58. // Add remote
  59. WIKI.logger.info('(STORAGE/GIT) Setting SSL Verification config...')
  60. await this.git.raw(['config', '--local', '--bool', 'http.sslVerify', _.toString(this.config.verifySSL)])
  61. switch (this.config.authType) {
  62. case 'ssh':
  63. WIKI.logger.info('(STORAGE/GIT) Setting SSH Command config...')
  64. if (this.config.sshPrivateKeyMode === 'contents') {
  65. try {
  66. this.config.sshPrivateKeyPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'secure/git-ssh.pem')
  67. await fs.outputFile(this.config.sshPrivateKeyPath, this.config.sshPrivateKeyContent + os.EOL, {
  68. encoding: 'utf8',
  69. mode: 0o600
  70. })
  71. } catch (err) {
  72. WIKI.logger.error(err)
  73. throw err
  74. }
  75. }
  76. await this.git.addConfig('core.sshCommand', `ssh -i "${this.config.sshPrivateKeyPath}" -o StrictHostKeyChecking=no`)
  77. WIKI.logger.info('(STORAGE/GIT) Adding origin remote via SSH...')
  78. await this.git.addRemote('origin', this.config.repoUrl)
  79. break
  80. default:
  81. WIKI.logger.info('(STORAGE/GIT) Adding origin remote via HTTP/S...')
  82. let originUrl = ''
  83. if (_.startsWith(this.config.repoUrl, 'http')) {
  84. originUrl = this.config.repoUrl.replace('://', `://${encodeURI(this.config.basicUsername)}:${encodeURI(this.config.basicPassword)}@`)
  85. } else {
  86. originUrl = `https://${encodeURI(this.config.basicUsername)}:${encodeURI(this.config.basicPassword)}@${this.config.repoUrl}`
  87. }
  88. await this.git.addRemote('origin', originUrl)
  89. break
  90. }
  91. // Fetch updates for remote
  92. WIKI.logger.info('(STORAGE/GIT) Fetch updates from remote...')
  93. await this.git.raw(['remote', 'update', 'origin'])
  94. // Checkout branch
  95. const branches = await this.git.branch()
  96. if (!_.includes(branches.all, this.config.branch) && !_.includes(branches.all, `remotes/origin/${this.config.branch}`)) {
  97. throw new Error('Invalid branch! Make sure it exists on the remote first.')
  98. }
  99. WIKI.logger.info(`(STORAGE/GIT) Checking out branch ${this.config.branch}...`)
  100. await this.git.checkout(this.config.branch)
  101. // Perform initial sync
  102. await this.sync()
  103. WIKI.logger.info('(STORAGE/GIT) Initialization completed.')
  104. },
  105. /**
  106. * SYNC
  107. */
  108. async sync() {
  109. const currentCommitLog = _.get(await this.git.log(['-n', '1', this.config.branch, '--']), 'latest', {})
  110. const rootUser = await WIKI.models.users.getRootUser()
  111. // Pull rebase
  112. if (_.includes(['sync', 'pull'], this.mode)) {
  113. WIKI.logger.info(`(STORAGE/GIT) Performing pull rebase from origin on branch ${this.config.branch}...`)
  114. await this.git.pull('origin', this.config.branch, ['--rebase'])
  115. }
  116. // Push
  117. if (_.includes(['sync', 'push'], this.mode)) {
  118. WIKI.logger.info(`(STORAGE/GIT) Performing push to origin on branch ${this.config.branch}...`)
  119. let pushOpts = ['--signed=if-asked']
  120. if (this.mode === 'push') {
  121. pushOpts.push('--force')
  122. }
  123. await this.git.push('origin', this.config.branch, pushOpts)
  124. }
  125. // Process Changes
  126. if (_.includes(['sync', 'pull'], this.mode)) {
  127. const latestCommitLog = _.get(await this.git.log(['-n', '1', this.config.branch, '--']), 'latest', {})
  128. const diff = await this.git.diffSummary(['-M', currentCommitLog.hash, latestCommitLog.hash])
  129. if (_.get(diff, 'files', []).length > 0) {
  130. let filesToProcess = []
  131. for (const f of diff.files) {
  132. const fMoved = f.file.split(' => ')
  133. const fName = fMoved.length === 2 ? fMoved[1] : fMoved[0]
  134. const fPath = path.join(this.repoPath, fName)
  135. let fStats = { size: 0 }
  136. try {
  137. fStats = await fs.stat(fPath)
  138. } catch (err) {
  139. if (err.code !== 'ENOENT') {
  140. WIKI.logger.warn(`(STORAGE/GIT) Failed to access file ${f.file}! Skipping...`)
  141. continue
  142. }
  143. }
  144. filesToProcess.push({
  145. ...f,
  146. file: {
  147. path: fPath,
  148. stats: fStats
  149. },
  150. oldPath: fMoved[0],
  151. relPath: fName
  152. })
  153. }
  154. await this.processFiles(filesToProcess, rootUser)
  155. }
  156. }
  157. },
  158. /**
  159. * Process Files
  160. *
  161. * @param {Array<String>} files Array of files to process
  162. */
  163. async processFiles(files, user) {
  164. for (const item of files) {
  165. const contentType = pageHelper.getContentType(item.relPath)
  166. const fileExists = await fs.pathExists(item.file.path)
  167. if (!item.binary && contentType) {
  168. // -> Page
  169. if (fileExists && !item.importAll && item.relPath !== item.oldPath) {
  170. // Page was renamed by git, so rename in DB
  171. WIKI.logger.info(`(STORAGE/GIT) Page marked as renamed: from ${item.oldPath} to ${item.relPath}`)
  172. const contentPath = pageHelper.getPagePath(item.oldPath)
  173. const contentDestinationPath = pageHelper.getPagePath(item.relPath)
  174. await WIKI.models.pages.movePage({
  175. user: user,
  176. path: contentPath.path,
  177. destinationPath: contentDestinationPath.path,
  178. locale: contentPath.locale,
  179. destinationLocale: contentPath.locale,
  180. skipStorage: true
  181. })
  182. } else if (!fileExists && !item.importAll && item.deletions > 0 && item.insertions === 0) {
  183. // Page was deleted by git, can safely mark as deleted in DB
  184. WIKI.logger.info(`(STORAGE/GIT) Page marked as deleted: ${item.relPath}`)
  185. const contentPath = pageHelper.getPagePath(item.relPath)
  186. await WIKI.models.pages.deletePage({
  187. user: user,
  188. path: contentPath.path,
  189. locale: contentPath.locale,
  190. skipStorage: true
  191. })
  192. continue
  193. }
  194. try {
  195. await commonDisk.processPage({
  196. user,
  197. relPath: item.relPath,
  198. fullPath: this.repoPath,
  199. contentType: contentType,
  200. moduleName: 'GIT'
  201. })
  202. } catch (err) {
  203. WIKI.logger.warn(`(STORAGE/GIT) Failed to process ${item.relPath}`)
  204. WIKI.logger.warn(err)
  205. }
  206. } else {
  207. // -> Asset
  208. if (fileExists && !item.importAll && ((item.before === item.after) || (item.deletions === 0 && item.insertions === 0))) {
  209. // Asset was renamed by git, so rename in DB
  210. WIKI.logger.info(`(STORAGE/GIT) Asset marked as renamed: from ${item.oldPath} to ${item.relPath}`)
  211. const fileHash = assetHelper.generateHash(item.relPath)
  212. const assetToRename = await WIKI.models.assets.query().findOne({ hash: fileHash })
  213. if (assetToRename) {
  214. await WIKI.models.assets.query().patch({
  215. filename: item.relPath,
  216. hash: fileHash
  217. }).findById(assetToRename.id)
  218. await assetToRename.deleteAssetCache()
  219. } else {
  220. WIKI.logger.info(`(STORAGE/GIT) Asset was not found in the DB, nothing to rename: ${item.relPath}`)
  221. }
  222. continue
  223. } else if (!fileExists && !item.importAll && ((item.before > 0 && item.after === 0) || (item.deletions > 0 && item.insertions === 0))) {
  224. // Asset was deleted by git, can safely mark as deleted in DB
  225. WIKI.logger.info(`(STORAGE/GIT) Asset marked as deleted: ${item.relPath}`)
  226. const fileHash = assetHelper.generateHash(item.relPath)
  227. const assetToDelete = await WIKI.models.assets.query().findOne({ hash: fileHash })
  228. if (assetToDelete) {
  229. await WIKI.models.knex('assetData').where('id', assetToDelete.id).del()
  230. await WIKI.models.assets.query().deleteById(assetToDelete.id)
  231. await assetToDelete.deleteAssetCache()
  232. } else {
  233. WIKI.logger.info(`(STORAGE/GIT) Asset was not found in the DB, nothing to delete: ${item.relPath}`)
  234. }
  235. continue
  236. }
  237. try {
  238. await commonDisk.processAsset({
  239. user,
  240. relPath: item.relPath,
  241. file: item.file,
  242. contentType: contentType,
  243. moduleName: 'GIT'
  244. })
  245. } catch (err) {
  246. WIKI.logger.warn(`(STORAGE/GIT) Failed to process asset ${item.relPath}`)
  247. WIKI.logger.warn(err)
  248. }
  249. }
  250. }
  251. },
  252. /**
  253. * CREATE
  254. *
  255. * @param {Object} page Page to create
  256. */
  257. async created(page) {
  258. WIKI.logger.info(`(STORAGE/GIT) Committing new file [${page.localeCode}] ${page.path}...`)
  259. let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
  260. if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
  261. fileName = `${page.localeCode}/${fileName}`
  262. }
  263. const filePath = path.join(this.repoPath, fileName)
  264. await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
  265. const gitFilePath = `./${fileName}`
  266. if ((await this.git.checkIgnore(gitFilePath)).length === 0) {
  267. await this.git.add(gitFilePath)
  268. await this.git.commit(`docs: create ${page.path}`, fileName, {
  269. '--author': `"${page.authorName} <${page.authorEmail}>"`
  270. })
  271. }
  272. },
  273. /**
  274. * UPDATE
  275. *
  276. * @param {Object} page Page to update
  277. */
  278. async updated(page) {
  279. WIKI.logger.info(`(STORAGE/GIT) Committing updated file [${page.localeCode}] ${page.path}...`)
  280. let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
  281. if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
  282. fileName = `${page.localeCode}/${fileName}`
  283. }
  284. const filePath = path.join(this.repoPath, fileName)
  285. await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
  286. const gitFilePath = `./${fileName}`
  287. if ((await this.git.checkIgnore(gitFilePath)).length === 0) {
  288. await this.git.add(gitFilePath)
  289. await this.git.commit(`docs: update ${page.path}`, fileName, {
  290. '--author': `"${page.authorName} <${page.authorEmail}>"`
  291. })
  292. }
  293. },
  294. /**
  295. * DELETE
  296. *
  297. * @param {Object} page Page to delete
  298. */
  299. async deleted(page) {
  300. WIKI.logger.info(`(STORAGE/GIT) Committing removed file [${page.localeCode}] ${page.path}...`)
  301. let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
  302. if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
  303. fileName = `${page.localeCode}/${fileName}`
  304. }
  305. const gitFilePath = `./${fileName}`
  306. if ((await this.git.checkIgnore(gitFilePath)).length === 0) {
  307. await this.git.rm(gitFilePath)
  308. await this.git.commit(`docs: delete ${page.path}`, fileName, {
  309. '--author': `"${page.authorName} <${page.authorEmail}>"`
  310. })
  311. }
  312. },
  313. /**
  314. * RENAME
  315. *
  316. * @param {Object} page Page to rename
  317. */
  318. async renamed(page) {
  319. WIKI.logger.info(`(STORAGE/GIT) Committing file move from [${page.localeCode}] ${page.path} to [${page.destinationLocaleCode}] ${page.destinationPath}...`)
  320. let sourceFileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
  321. let destinationFileName = `${page.destinationPath}.${pageHelper.getFileExtension(page.contentType)}`
  322. if (WIKI.config.lang.namespacing) {
  323. if (WIKI.config.lang.code !== page.localeCode) {
  324. sourceFileName = `${page.localeCode}/${sourceFileName}`
  325. }
  326. if (WIKI.config.lang.code !== page.destinationLocaleCode) {
  327. destinationFileName = `${page.destinationLocaleCode}/${destinationFileName}`
  328. }
  329. }
  330. const sourceFilePath = path.join(this.repoPath, sourceFileName)
  331. const destinationFilePath = path.join(this.repoPath, destinationFileName)
  332. await fs.move(sourceFilePath, destinationFilePath)
  333. await this.git.rm(`./${sourceFileName}`)
  334. await this.git.add(`./${destinationFileName}`)
  335. await this.git.commit(`docs: rename ${page.path} to ${page.destinationPath}`, [sourceFilePath, destinationFilePath], {
  336. '--author': `"${page.moveAuthorName} <${page.moveAuthorEmail}>"`
  337. })
  338. },
  339. /**
  340. * ASSET UPLOAD
  341. *
  342. * @param {Object} asset Asset to upload
  343. */
  344. async assetUploaded (asset) {
  345. WIKI.logger.info(`(STORAGE/GIT) Committing new file ${asset.path}...`)
  346. const filePath = path.join(this.repoPath, asset.path)
  347. await fs.outputFile(filePath, asset.data, 'utf8')
  348. await this.git.add(`./${asset.path}`)
  349. await this.git.commit(`docs: upload ${asset.path}`, asset.path, {
  350. '--author': `"${asset.authorName} <${asset.authorEmail}>"`
  351. })
  352. },
  353. /**
  354. * ASSET DELETE
  355. *
  356. * @param {Object} asset Asset to upload
  357. */
  358. async assetDeleted (asset) {
  359. WIKI.logger.info(`(STORAGE/GIT) Committing removed file ${asset.path}...`)
  360. await this.git.rm(`./${asset.path}`)
  361. await this.git.commit(`docs: delete ${asset.path}`, asset.path, {
  362. '--author': `"${asset.authorName} <${asset.authorEmail}>"`
  363. })
  364. },
  365. /**
  366. * ASSET RENAME
  367. *
  368. * @param {Object} asset Asset to upload
  369. */
  370. async assetRenamed (asset) {
  371. WIKI.logger.info(`(STORAGE/GIT) Committing file move from ${asset.path} to ${asset.destinationPath}...`)
  372. await this.git.mv(`./${asset.path}`, `./${asset.destinationPath}`)
  373. await this.git.commit(`docs: rename ${asset.path} to ${asset.destinationPath}`, [asset.path, asset.destinationPath], {
  374. '--author': `"${asset.moveAuthorName} <${asset.moveAuthorEmail}>"`
  375. })
  376. },
  377. async getLocalLocation (asset) {
  378. return path.join(this.repoPath, asset.path)
  379. },
  380. /**
  381. * HANDLERS
  382. */
  383. async importAll() {
  384. WIKI.logger.info(`(STORAGE/GIT) Importing all content from local Git repo to the DB...`)
  385. const rootUser = await WIKI.models.users.getRootUser()
  386. await pipeline(
  387. klaw(this.repoPath, {
  388. filter: (f) => {
  389. return !_.includes(f, '.git')
  390. }
  391. }),
  392. new stream.Transform({
  393. objectMode: true,
  394. transform: async (file, enc, cb) => {
  395. const relPath = file.path.substr(this.repoPath.length + 1)
  396. if (file.stats.size < 1) {
  397. // Skip directories and zero-byte files
  398. return cb()
  399. } else if (relPath && relPath.length > 3) {
  400. WIKI.logger.info(`(STORAGE/GIT) Processing ${relPath}...`)
  401. await this.processFiles([{
  402. user: rootUser,
  403. relPath,
  404. file,
  405. deletions: 0,
  406. insertions: 0,
  407. importAll: true
  408. }], rootUser)
  409. }
  410. cb()
  411. }
  412. })
  413. )
  414. commonDisk.clearFolderCache()
  415. WIKI.logger.info('(STORAGE/GIT) Import completed.')
  416. },
  417. async syncUntracked() {
  418. WIKI.logger.info(`(STORAGE/GIT) Adding all untracked content...`)
  419. // -> Pages
  420. await pipeline(
  421. WIKI.models.knex.column('id', 'path', 'localeCode', 'title', 'description', 'contentType', 'content', 'isPublished', 'updatedAt', 'createdAt', 'editorKey').select().from('pages').where({
  422. isPrivate: false
  423. }).stream(),
  424. new stream.Transform({
  425. objectMode: true,
  426. transform: async (page, enc, cb) => {
  427. const pageObject = await WIKI.models.pages.query().findById(page.id)
  428. page.tags = await pageObject.$relatedQuery('tags')
  429. let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
  430. if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
  431. fileName = `${page.localeCode}/${fileName}`
  432. }
  433. WIKI.logger.info(`(STORAGE/GIT) Adding page ${fileName}...`)
  434. const filePath = path.join(this.repoPath, fileName)
  435. await fs.outputFile(filePath, pageHelper.injectPageMetadata(page), 'utf8')
  436. await this.git.add(`./${fileName}`)
  437. cb()
  438. }
  439. })
  440. )
  441. // -> Assets
  442. const assetFolders = await WIKI.models.assetFolders.getAllPaths()
  443. await pipeline(
  444. WIKI.models.knex.column('filename', 'folderId', 'data').select().from('assets').join('assetData', 'assets.id', '=', 'assetData.id').stream(),
  445. new stream.Transform({
  446. objectMode: true,
  447. transform: async (asset, enc, cb) => {
  448. const filename = (asset.folderId && asset.folderId > 0) ? `${_.get(assetFolders, asset.folderId)}/${asset.filename}` : asset.filename
  449. WIKI.logger.info(`(STORAGE/GIT) Adding asset ${filename}...`)
  450. await fs.outputFile(path.join(this.repoPath, filename), asset.data)
  451. await this.git.add(`./${filename}`)
  452. cb()
  453. }
  454. })
  455. )
  456. await this.git.commit(`docs: add all untracked content`)
  457. WIKI.logger.info('(STORAGE/GIT) All content is now tracked.')
  458. },
  459. async purge() {
  460. WIKI.logger.info(`(STORAGE/GIT) Purging local repository...`)
  461. await fs.emptyDir(this.repoPath)
  462. WIKI.logger.info('(STORAGE/GIT) Local repository is now empty. Reinitializing...')
  463. await this.init()
  464. }
  465. }