storage.js 16 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
    }

Nick's avatar
Nick committed
48
    // Set default author
49 50
    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
51 52 53

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

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

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

    // Checkout branch
101
    const branches = await this.git.branch()
Nick's avatar
Nick committed
102 103 104 105
    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}...`)
106 107 108 109
    await this.git.checkout(this.config.branch)

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

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

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

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

    // 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')
      }
134
      await this.git.push('origin', this.config.branch, pushOpts)
Nick's avatar
Nick committed
135
    }
Nick's avatar
Nick committed
136 137 138 139 140

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

141
      const diff = await this.git.diffSummary(['-M', currentCommitLog.hash, latestCommitLog.hash])
Nick's avatar
Nick committed
142
      if (_.get(diff, 'files', []).length > 0) {
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
        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)
166 167 168 169 170 171 172 173
      }
    }
  },
  /**
   * Process Files
   *
   * @param {Array<String>} files Array of files to process
   */
174
  async processFiles(files, user) {
175
    for (const item of files) {
176 177 178 179 180 181 182 183 184 185 186
      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.models.pages.deletePage({
187 188 189 190
            path: contentPath.path,
            locale: contentPath.locale,
            skipStorage: true
          })
191
          continue
192
        }
Nick's avatar
Nick committed
193

194 195 196 197 198 199 200
        try {
          await commonDisk.processPage({
            user,
            relPath: item.relPath,
            fullPath: this.repoPath,
            contentType: contentType,
            moduleName: 'GIT'
201
          })
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
        } 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.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}`)
235
          WIKI.logger.warn(err)
Nick's avatar
Nick committed
236 237 238
        }
      }
    }
239
  },
Nick's avatar
Nick committed
240 241 242 243 244
  /**
   * CREATE
   *
   * @param {Object} page Page to create
   */
245
  async created(page) {
246
    WIKI.logger.info(`(STORAGE/GIT) Committing new file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
247
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
248 249 250
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
251
    const filePath = path.join(this.repoPath, fileName)
252
    await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
253 254 255 256

    await this.git.add(`./${fileName}`)
    await this.git.commit(`docs: create ${page.path}`, fileName, {
      '--author': `"${page.authorName} <${page.authorEmail}>"`
Nick's avatar
Nick committed
257
    })
258
  },
Nick's avatar
Nick committed
259 260 261 262 263
  /**
   * UPDATE
   *
   * @param {Object} page Page to update
   */
264
  async updated(page) {
265
    WIKI.logger.info(`(STORAGE/GIT) Committing updated file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
266
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
267 268 269
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
270
    const filePath = path.join(this.repoPath, fileName)
271
    await fs.outputFile(filePath, page.injectMetadata(), 'utf8')
272 273 274 275

    await this.git.add(`./${fileName}`)
    await this.git.commit(`docs: update ${page.path}`, fileName, {
      '--author': `"${page.authorName} <${page.authorEmail}>"`
Nick's avatar
Nick committed
276
    })
277
  },
Nick's avatar
Nick committed
278 279 280 281 282
  /**
   * DELETE
   *
   * @param {Object} page Page to delete
   */
283
  async deleted(page) {
284
    WIKI.logger.info(`(STORAGE/GIT) Committing removed file [${page.localeCode}] ${page.path}...`)
NGPixel's avatar
NGPixel committed
285
    let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
286 287 288
    if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
      fileName = `${page.localeCode}/${fileName}`
    }
289

290 291 292
    await this.git.rm(`./${fileName}`)
    await this.git.commit(`docs: delete ${page.path}`, fileName, {
      '--author': `"${page.authorName} <${page.authorEmail}>"`
Nick's avatar
Nick committed
293
    })
294
  },
Nick's avatar
Nick committed
295 296 297 298 299
  /**
   * RENAME
   *
   * @param {Object} page Page to rename
   */
300
  async renamed(page) {
301
    WIKI.logger.info(`(STORAGE/GIT) Committing file move from [${page.localeCode}] ${page.path} to [${page.destinationLocaleCode}] ${page.destinationPath}...`)
302 303
    let sourceFileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
    let destinationFileName = `${page.destinationPath}.${pageHelper.getFileExtension(page.contentType)}`
304

NGPixel's avatar
NGPixel committed
305 306
    if (WIKI.config.lang.namespacing) {
      if (WIKI.config.lang.code !== page.localeCode) {
307
        sourceFileName = `${page.localeCode}/${sourceFileName}`
NGPixel's avatar
NGPixel committed
308 309
      }
      if (WIKI.config.lang.code !== page.destinationLocaleCode) {
310
        destinationFileName = `${page.destinationLocaleCode}/${destinationFileName}`
NGPixel's avatar
NGPixel committed
311
      }
312
    }
313

314 315 316 317 318 319
    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}`)
320
    await this.git.commit(`docs: rename ${page.path} to ${page.destinationPath}`, [sourceFilePath, destinationFilePath], {
NGPixel's avatar
NGPixel committed
321
      '--author': `"${page.moveAuthorName} <${page.moveAuthorEmail}>"`
Nick's avatar
Nick committed
322
    })
323
  },
324 325 326 327 328 329 330 331
  /**
   * 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)
332
    await fs.outputFile(filePath, asset.data, 'utf8')
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357

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

360 361 362
    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}>"`
363 364
    })
  },
365 366 367 368 369
  /**
   * HANDLERS
   */
  async importAll() {
    WIKI.logger.info(`(STORAGE/GIT) Importing all content from local Git repo to the DB...`)
370 371 372

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

373 374 375 376 377 378 379 380 381 382
    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)
383 384 385 386
          if (file.stats.size < 1) {
            // Skip directories and zero-byte files
            return cb()
          } else if (relPath && relPath.length > 3) {
387 388
            WIKI.logger.info(`(STORAGE/GIT) Processing ${relPath}...`)
            await this.processFiles([{
389
              user: rootUser,
390 391
              relPath,
              file,
392 393
              deletions: 0,
              insertions: 0
394
            }], rootUser)
395 396 397 398 399
          }
          cb()
        }
      })
    )
400 401 402

    commonDisk.clearFolderCache()

403 404 405 406
    WIKI.logger.info('(STORAGE/GIT) Import completed.')
  },
  async syncUntracked() {
    WIKI.logger.info(`(STORAGE/GIT) Adding all untracked content...`)
407

408
    // -> Pages
409 410 411 412 413 414 415
    await pipeline(
      WIKI.models.knex.column('path', 'localeCode', 'title', 'description', 'contentType', 'content', 'isPublished', 'updatedAt').select().from('pages').where({
        isPrivate: false
      }).stream(),
      new stream.Transform({
        objectMode: true,
        transform: async (page, enc, cb) => {
Nick's avatar
Nick committed
416
          let fileName = `${page.path}.${pageHelper.getFileExtension(page.contentType)}`
417 418 419
          if (WIKI.config.lang.namespacing && WIKI.config.lang.code !== page.localeCode) {
            fileName = `${page.localeCode}/${fileName}`
          }
420
          WIKI.logger.info(`(STORAGE/GIT) Adding page ${fileName}...`)
421 422 423 424 425 426 427
          const filePath = path.join(this.repoPath, fileName)
          await fs.outputFile(filePath, pageHelper.injectPageMetadata(page), 'utf8')
          await this.git.add(`./${fileName}`)
          cb()
        }
      })
    )
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444

    // -> 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()
        }
      })
    )
445

446 447
    await this.git.commit(`docs: add all untracked content`)
    WIKI.logger.info('(STORAGE/GIT) All content is now tracked.')
448 449 450 451 452 453
  },
  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()
454 455
  }
}