storage.js 19.1 KB
Newer Older
Nick's avatar
Nick committed
1 2 3 4
const path = require('path')
const sgit = require('simple-git/promise')
const fs = require('fs-extra')
const _ = require('lodash')
5 6 7 8
const stream = require('stream')
const Promise = require('bluebird')
const pipeline = Promise.promisify(stream.pipeline)
const klaw = require('klaw')
9
const os = require('os')
10 11 12 13

const pageHelper = require('../../../helpers/page')
const assetHelper = require('../../../helpers/asset')
const commonDisk = require('../disk/common')
Nick's avatar
Nick committed
14

15 16
/* global WIKI */

17
module.exports = {
18
  git: null,
19
  repoPath: path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'repo'),
20
  async activated() {
21
    // not used
22
  },
23
  async deactivated() {
24
    // not used
25
  },
Nick's avatar
Nick committed
26 27 28
  /**
   * INIT
   */
29
  async init() {
Nick's avatar
Nick committed
30
    WIKI.logger.info('(STORAGE/GIT) Initializing...')
31 32 33
    this.repoPath = path.resolve(WIKI.ROOTPATH, this.config.localRepoPath)
    await fs.ensureDir(this.repoPath)
    this.git = sgit(this.repoPath)
Nick's avatar
Nick committed
34

35 36 37 38 39
    // Set custom binary path
    if (!_.isEmpty(this.config.gitBinaryPath)) {
      this.git.customBinary(this.config.gitBinaryPath)
    }

Nick's avatar
Nick committed
40
    // Initialize repo (if needed)
Nick's avatar
Nick committed
41
    WIKI.logger.info('(STORAGE/GIT) Checking repository state...')
42
    const isRepo = await this.git.checkIsRepo()
Nick's avatar
Nick committed
43
    if (!isRepo) {
Nick's avatar
Nick committed
44
      WIKI.logger.info('(STORAGE/GIT) Initializing local repository...')
45
      await this.git.init()
Nick's avatar
Nick committed
46 47
    }

48
    // Disable quotePath, color output
49 50
    // Link https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
    await this.git.raw(['config', '--local', 'core.quotepath', false])
51
    await this.git.raw(['config', '--local', 'color.ui', false])
52

Nick's avatar
Nick committed
53
    // Set default author
54 55
    await this.git.raw(['config', '--local', 'user.email', this.config.defaultEmail])
    await this.git.raw(['config', '--local', 'user.name', this.config.defaultName])
Nick's avatar
Nick committed
56 57 58

    // Purge existing remotes
    WIKI.logger.info('(STORAGE/GIT) Listing existing remotes...')
59
    const remotes = await this.git.getRemotes()
Nick's avatar
Nick committed
60 61
    if (remotes.length > 0) {
      WIKI.logger.info('(STORAGE/GIT) Purging existing remotes...')
62
      for (let remote of remotes) {
63
        await this.git.removeRemote(remote.name)
Nick's avatar
Nick committed
64 65
      }
    }
66

Nick's avatar
Nick committed
67
    // Add remote
Nick's avatar
Nick committed
68
    WIKI.logger.info('(STORAGE/GIT) Setting SSL Verification config...')
69
    await this.git.raw(['config', '--local', '--bool', 'http.sslVerify', _.toString(this.config.verifySSL)])
Nick's avatar
Nick committed
70 71
    switch (this.config.authType) {
      case 'ssh':
Nick's avatar
Nick committed
72
        WIKI.logger.info('(STORAGE/GIT) Setting SSH Command config...')
73 74
        if (this.config.sshPrivateKeyMode === 'contents') {
          try {
75
            this.config.sshPrivateKeyPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'secure/git-ssh.pem')
76
            await fs.outputFile(this.config.sshPrivateKeyPath, this.config.sshPrivateKeyContent + os.EOL, {
77 78 79 80
              encoding: 'utf8',
              mode: 0o600
            })
          } catch (err) {
81
            WIKI.logger.error(err)
82 83 84
            throw err
          }
        }
85
        await this.git.addConfig('core.sshCommand', `ssh -i "${this.config.sshPrivateKeyPath}" -o StrictHostKeyChecking=no`)
Nick's avatar
Nick committed
86
        WIKI.logger.info('(STORAGE/GIT) Adding origin remote via SSH...')
87
        await this.git.addRemote('origin', this.config.repoUrl)
Nick's avatar
Nick committed
88 89
        break
      default:
90 91 92
        WIKI.logger.info('(STORAGE/GIT) Adding origin remote via HTTP/S...')
        let originUrl = ''
        if (_.startsWith(this.config.repoUrl, 'http')) {
93
          originUrl = this.config.repoUrl.replace('://', `://${encodeURI(this.config.basicUsername)}:${encodeURI(this.config.basicPassword)}@`)
94
        } else {
95
          originUrl = `https://${encodeURI(this.config.basicUsername)}:${encodeURI(this.config.basicPassword)}@${this.config.repoUrl}`
96
        }
Nick's avatar
Nick committed
97
        await this.git.addRemote('origin', originUrl)
Nick's avatar
Nick committed
98 99
        break
    }
Nick's avatar
Nick committed
100 101 102

    // Fetch updates for remote
    WIKI.logger.info('(STORAGE/GIT) Fetch updates from remote...')
103
    await this.git.raw(['remote', 'update', 'origin'])
Nick's avatar
Nick committed
104 105

    // Checkout branch
106
    const branches = await this.git.branch()
Nick's avatar
Nick committed
107 108 109 110
    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}...`)
111 112 113 114
    await this.git.checkout(this.config.branch)

    // Perform initial sync
    await this.sync()
Nick's avatar
Nick committed
115

116 117
    WIKI.logger.info('(STORAGE/GIT) Initialization completed.')
  },
Nick's avatar
Nick committed
118 119 120
  /**
   * SYNC
   */
121
  async sync() {
122
    const currentCommitLog = _.get(await this.git.log(['-n', '1', this.config.branch, '--']), 'latest', {})
Nick's avatar
Nick committed
123

124 125
    const rootUser = await WIKI.models.users.getRootUser()

Nick's avatar
Nick committed
126 127 128
    // Pull rebase
    if (_.includes(['sync', 'pull'], this.mode)) {
      WIKI.logger.info(`(STORAGE/GIT) Performing pull rebase from origin on branch ${this.config.branch}...`)
129
      await this.git.pull('origin', this.config.branch, ['--rebase'])
Nick's avatar
Nick committed
130 131 132 133 134 135 136 137 138
    }

    // 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')
      }
139
      await this.git.push('origin', this.config.branch, pushOpts)
Nick's avatar
Nick committed
140
    }
Nick's avatar
Nick committed
141 142 143

    // Process Changes
    if (_.includes(['sync', 'pull'], this.mode)) {
144
      const latestCommitLog = _.get(await this.git.log(['-n', '1', this.config.branch, '--']), 'latest', {})
Nick's avatar
Nick committed
145

146
      const diff = await this.git.diffSummary(['-M', currentCommitLog.hash, latestCommitLog.hash])
Nick's avatar
Nick committed
147
      if (_.get(diff, 'files', []).length > 0) {
148
        let filesToProcess = []
149
        const filePattern = /(.*?)(?:{(.*?))? => (?:(.*?)})?(.*)/
150
        for (const f of diff.files) {
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
          const fMatch = f.file.match(filePattern)
          const fNames = {
            old: null,
            new: null
          }
          if (!fMatch) {
            fNames.old = f.file
            fNames.new = f.file
          } else if (!fMatch[2] && !fMatch[3]) {
            fNames.old = fMatch[1]
            fNames.new = fMatch[4]
          } else {
            fNames.old = (fMatch[1]+fMatch[2]+fMatch[4]).replace('//', '/'),
            fNames.new = (fMatch[1]+fMatch[3]+fMatch[4]).replace('//', '/')
          }
          const fPath = path.join(this.repoPath, fNames.new)
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
          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
            },
183 184
            oldPath: fNames.old,
            relPath: fNames.new
185 186 187
          })
        }
        await this.processFiles(filesToProcess, rootUser)
188 189 190 191 192 193 194 195
      }
    }
  },
  /**
   * Process Files
   *
   * @param {Array<String>} files Array of files to process
   */
196
  async processFiles(files, user) {
197
    for (const item of files) {
198
      const contentType = pageHelper.getContentType(item.relPath)
199
      const fileExists = await fs.pathExists(item.file.path)
200 201 202
      if (!item.binary && contentType) {
        // -> Page

203
        if (fileExists && !item.importAll && item.relPath !== item.oldPath) {
204 205 206 207 208 209 210 211 212 213 214 215 216
          // Page was renamed by git, so rename in DB
          WIKI.logger.info(`(STORAGE/GIT) Page marked as renamed: from ${item.oldPath} to ${item.relPath}`)

          const contentPath = pageHelper.getPagePath(item.oldPath)
          const contentDestinationPath = pageHelper.getPagePath(item.relPath)
          await WIKI.models.pages.movePage({
            user: user,
            path: contentPath.path,
            destinationPath: contentDestinationPath.path,
            locale: contentPath.locale,
            destinationLocale: contentPath.locale,
            skipStorage: true
          })
217
        } else if (!fileExists && !item.importAll && item.deletions > 0 && item.insertions === 0) {
218 219 220 221 222
          // 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.models.pages.deletePage({
223
            user: user,
224 225 226 227
            path: contentPath.path,
            locale: contentPath.locale,
            skipStorage: true
          })
228
          continue
229
        }
Nick's avatar
Nick committed
230

231 232 233 234 235 236 237
        try {
          await commonDisk.processPage({
            user,
            relPath: item.relPath,
            fullPath: this.repoPath,
            contentType: contentType,
            moduleName: 'GIT'
238
          })
239 240 241 242 243 244 245
        } catch (err) {
          WIKI.logger.warn(`(STORAGE/GIT) Failed to process ${item.relPath}`)
          WIKI.logger.warn(err)
        }
      } else {
        // -> Asset

246
        if (fileExists && !item.importAll && ((item.before === item.after) || (item.deletions === 0 && item.insertions === 0))) {
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
          // Asset was renamed by git, so rename in DB
          WIKI.logger.info(`(STORAGE/GIT) Asset marked as renamed: from ${item.oldPath} to ${item.relPath}`)

          const fileHash = assetHelper.generateHash(item.relPath)
          const assetToRename = await WIKI.models.assets.query().findOne({ hash: fileHash })
          if (assetToRename) {
            await WIKI.models.assets.query().patch({
              filename: item.relPath,
              hash: fileHash
            }).findById(assetToRename.id)
            await assetToRename.deleteAssetCache()
          } else {
            WIKI.logger.info(`(STORAGE/GIT) Asset was not found in the DB, nothing to rename: ${item.relPath}`)
          }
          continue
262
        } else if (!fileExists && !item.importAll && ((item.before > 0 && item.after === 0) || (item.deletions > 0 && item.insertions === 0))) {
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287
          // 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.models.assets.query().findOne({ hash: fileHash })
          if (assetToDelete) {
            await WIKI.models.knex('assetData').where('id', assetToDelete.id).del()
            await WIKI.models.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}`)
288
          WIKI.logger.warn(err)
Nick's avatar
Nick committed
289 290 291
        }
      }
    }
292
  },
Nick's avatar
Nick committed
293 294 295 296 297
  /**
   * CREATE
   *
   * @param {Object} page Page to create
   */
298
  async created(page) {
299
    WIKI.logger.info(`(STORAGE/GIT) Committing new file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
300
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
301 302 303
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
304
    const filePath = path.join(this.repoPath, fileName)
305
    await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
306

307 308 309 310 311 312 313
    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}>"`
      })
    }
314
  },
Nick's avatar
Nick committed
315 316 317 318 319
  /**
   * UPDATE
   *
   * @param {Object} page Page to update
   */
320
  async updated(page) {
321
    WIKI.logger.info(`(STORAGE/GIT) Committing updated file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
322
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
323 324 325
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
326
    const filePath = path.join(this.repoPath, fileName)
327
    await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
328

329 330 331 332 333 334 335
    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}>"`
      })
    }
336
  },
Nick's avatar
Nick committed
337 338 339 340 341
  /**
   * DELETE
   *
   * @param {Object} page Page to delete
   */
342
  async deleted(page) {
343
    WIKI.logger.info(`(STORAGE/GIT) Committing removed file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
344
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
345 346 347
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
348

349 350 351 352 353 354 355
    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}>"`
      })
    }
356
  },
Nick's avatar
Nick committed
357 358 359 360 361
  /**
   * RENAME
   *
   * @param {Object} page Page to rename
   */
362
  async renamed(page) {
363
    WIKI.logger.info(`(STORAGE/GIT) Committing file move from [${page.localeCode}] ${page.path} to [${page.destinationLocaleCode}] ${page.destinationPath}...`)
364 365
    let sourceFileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
    let destinationFileName = `${page.destinationPath}.${pageHelper.getFileExtension(page.contentType)}`
366

NGPixel's avatar
NGPixel committed
367 368
    if (WIKI.config.lang.namespacing) {
      if (WIKI.config.lang.code !== page.localeCode) {
369
        sourceFileName = `${page.localeCode}/${sourceFileName}`
NGPixel's avatar
NGPixel committed
370 371
      }
      if (WIKI.config.lang.code !== page.destinationLocaleCode) {
372
        destinationFileName = `${page.destinationLocaleCode}/${destinationFileName}`
NGPixel's avatar
NGPixel committed
373
      }
374
    }
375

376 377 378 379 380 381
    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}`)
382
    await this.git.commit(`docs: rename ${page.path} to ${page.destinationPath}`, [sourceFilePath, destinationFilePath], {
NGPixel's avatar
NGPixel committed
383
      '--author': `"${page.moveAuthorName} <${page.moveAuthorEmail}>"`
Nick's avatar
Nick committed
384
    })
385
  },
386 387 388 389 390 391 392 393
  /**
   * 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)
394
    await fs.outputFile(filePath, asset.data, 'utf8')
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419

    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) {
420
    WIKI.logger.info(`(STORAGE/GIT) Committing file move from ${asset.path} to ${asset.destinationPath}...`)
421

422 423 424
    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}>"`
425 426
    })
  },
427 428 429
  async getLocalLocation (asset) {
    return path.join(this.repoPath, asset.path)
  },
430 431 432 433 434
  /**
   * HANDLERS
   */
  async importAll() {
    WIKI.logger.info(`(STORAGE/GIT) Importing all content from local Git repo to the DB...`)
435 436 437

    const rootUser = await WIKI.models.users.getRootUser()

438 439 440 441 442 443 444 445 446 447
    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)
448 449 450 451
          if (file.stats.size < 1) {
            // Skip directories and zero-byte files
            return cb()
          } else if (relPath && relPath.length > 3) {
452 453
            WIKI.logger.info(`(STORAGE/GIT) Processing ${relPath}...`)
            await this.processFiles([{
454
              user: rootUser,
455 456
              relPath,
              file,
457
              deletions: 0,
458 459
              insertions: 0,
              importAll: true
460
            }], rootUser)
461 462 463 464 465
          }
          cb()
        }
      })
    )
466 467 468

    commonDisk.clearFolderCache()

469 470 471 472
    WIKI.logger.info('(STORAGE/GIT) Import completed.')
  },
  async syncUntracked() {
    WIKI.logger.info(`(STORAGE/GIT) Adding all untracked content...`)
473

474
    // -> Pages
475
    await pipeline(
476
      WIKI.models.knex.column('id', 'path', 'localeCode', 'title', 'description', 'contentType', 'content', 'isPublished', 'updatedAt', 'createdAt', 'editorKey').select().from('pages').where({
477 478 479 480 481
        isPrivate: false
      }).stream(),
      new stream.Transform({
        objectMode: true,
        transform: async (page, enc, cb) => {
482 483
          const pageObject = await WIKI.models.pages.query().findById(page.id)
          page.tags = await pageObject.$relatedQuery('tags')
484

Nick's avatar
Nick committed
485
          let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
486 487 488
          if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
            fileName = `${page.localeCode}/${fileName}`
          }
489
          WIKI.logger.info(`(STORAGE/GIT) Adding page ${fileName}...`)
490 491 492 493 494 495 496
          const filePath = path.join(this.repoPath, fileName)
          await fs.outputFile(filePath, pageHelper.injectPageMetadata(page), 'utf8')
          await this.git.add(`./${fileName}`)
          cb()
        }
      })
    )
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513

    // -> Assets
    const assetFolders = await WIKI.models.assetFolders.getAllPaths()

    await pipeline(
      WIKI.models.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()
        }
      })
    )
514

515 516
    await this.git.commit(`docs: add all untracked content`)
    WIKI.logger.info('(STORAGE/GIT) All content is now tracked.')
517 518 519 520 521 522
  },
  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()
523 524
  }
}