hackmd.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /* eslint-disable no-use-before-define */
  2. const logger = require('@alias/logger')('growi:routes:hackmd');
  3. const path = require('path');
  4. const fs = require('graceful-fs');
  5. const swig = require('swig-templates');
  6. const axios = require('axios');
  7. const ApiResponse = require('../util/apiResponse');
  8. /**
  9. * @swagger
  10. *
  11. * components:
  12. * schemas:
  13. * Hackmd:
  14. * description: Hackmd
  15. * type: object
  16. * properties:
  17. * pageIdOnHackmd:
  18. * type: string
  19. * description: page ID on HackMD
  20. * example: qLnodHLxT6C3hVEVczvbDQ
  21. * revisionIdHackmdSynced:
  22. * $ref: '#/components/schemas/Revision/properties/_id'
  23. * hasDraftOnHackmd:
  24. * type: boolean
  25. * description: has draft on HackMD
  26. * example: false
  27. */
  28. module.exports = function(crowi, app) {
  29. const Page = crowi.models.Page;
  30. const pageEvent = crowi.event('page');
  31. // load GROWI agent script for HackMD
  32. const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
  33. const agentScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-agent.js']);
  34. const stylesScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-styles.js']);
  35. // generate swig template
  36. let agentScriptContentTpl;
  37. let stylesScriptContentTpl;
  38. // init 'saveOnHackmd' event
  39. pageEvent.on('saveOnHackmd', (page) => {
  40. crowi.getIo().sockets.emit('page:editingWithHackmd', { page });
  41. });
  42. /**
  43. * GET /_hackmd/load-agent
  44. *
  45. * loadAgent action
  46. * This should be access from HackMD and send agent script
  47. *
  48. * @param {object} req
  49. * @param {object} res
  50. */
  51. const loadAgent = function(req, res) {
  52. // generate swig template
  53. if (agentScriptContentTpl == null) {
  54. agentScriptContentTpl = swig.compileFile(agentScriptPath);
  55. }
  56. const origin = crowi.appService.getSiteUrl();
  57. // generate definitions to replace
  58. const definitions = {
  59. origin,
  60. };
  61. // inject
  62. const script = agentScriptContentTpl(definitions);
  63. res.set('Content-Type', 'application/javascript');
  64. res.send(script);
  65. };
  66. /**
  67. * GET /_hackmd/load-styles
  68. *
  69. * loadStyles action
  70. * This should be access from HackMD and send script to insert styles
  71. *
  72. * @param {object} req
  73. * @param {object} res
  74. */
  75. const loadStyles = function(req, res) {
  76. // generate swig template
  77. if (stylesScriptContentTpl == null) {
  78. stylesScriptContentTpl = swig.compileFile(stylesScriptPath);
  79. }
  80. const styleFilePath = path.join(crowi.publicDir, manifest['styles/style-hackmd.css']);
  81. const styles = fs
  82. .readFileSync(styleFilePath).toString()
  83. .replace(/\s+/g, ' ');
  84. // generate definitions to replace
  85. const definitions = {
  86. styles: escape(styles),
  87. };
  88. // inject
  89. const script = stylesScriptContentTpl(definitions);
  90. res.set('Content-Type', 'application/javascript');
  91. res.send(script);
  92. };
  93. const validateForApi = async function(req, res, next) {
  94. // validate process.env.HACKMD_URI
  95. const hackmdUri = process.env.HACKMD_URI;
  96. if (hackmdUri == null) {
  97. return res.json(ApiResponse.error('HackMD for GROWI has not been setup'));
  98. }
  99. // validate pageId
  100. const pageId = req.body.pageId;
  101. if (pageId == null) {
  102. return res.json(ApiResponse.error('pageId required'));
  103. }
  104. // validate page
  105. const page = await Page.findOne({ _id: pageId });
  106. if (page == null) {
  107. return res.json(ApiResponse.error(`Page('${pageId}') does not exist`));
  108. }
  109. req.page = page;
  110. next();
  111. };
  112. /**
  113. * @swagger
  114. *
  115. * /hackmd.integrate:
  116. * post:
  117. * tags: [Hackmd]
  118. * operationId: integrateHackmd
  119. * summary: /hackmd.integrate
  120. * description: Integrate hackmd
  121. * requestBody:
  122. * content:
  123. * application/json:
  124. * schema:
  125. * properties:
  126. * pageId:
  127. * $ref: '#/components/schemas/Page/properties/_id'
  128. * page:
  129. * $ref: '#/components/schemas/Hackmd'
  130. * responses:
  131. * 200:
  132. * description: Succeeded to integrate HackMD.
  133. * content:
  134. * application/json:
  135. * schema:
  136. * properties:
  137. * ok:
  138. * $ref: '#/components/schemas/V1Response/properties/ok'
  139. * pageIdOnHackmd:
  140. * $ref: '#/components/schemas/Hackmd/properties/pageIdOnHackmd'
  141. * revisionIdHackmdSynced:
  142. * $ref: '#/components/schemas/Hackmd/properties/revisionIdHackmdSynced'
  143. * hasDraftOnHackmd:
  144. * $ref: '#/components/schemas/Hackmd/properties/hasDraftOnHackmd'
  145. * 403:
  146. * $ref: '#/components/responses/403'
  147. * 500:
  148. * $ref: '#/components/responses/500'
  149. */
  150. /**
  151. * POST /_api/hackmd.integrate
  152. *
  153. * Create page on HackMD and start to integrate
  154. * @param {object} req
  155. * @param {object} res
  156. */
  157. const integrate = async function(req, res) {
  158. const hackmdUri = process.env.HACKMD_URI_FOR_SERVER || process.env.HACKMD_URI;
  159. let page = req.page;
  160. const hackmdPageUri = (page.pageIdOnHackmd != null)
  161. ? `${hackmdUri}/${page.pageIdOnHackmd}`
  162. : `${hackmdUri}/new`;
  163. let hackmdResponse;
  164. try {
  165. // check if page is found or created in HackMD
  166. hackmdResponse = await axios.get(hackmdPageUri, {
  167. maxRedirects: 0,
  168. // validate HTTP status is 200 or 302 or 404
  169. validateStatus: (status) => {
  170. return status === 200 || status === 302 || status === 404;
  171. },
  172. });
  173. }
  174. catch (err) {
  175. logger.error(err);
  176. return res.json(ApiResponse.error(err));
  177. }
  178. const { status, headers } = hackmdResponse;
  179. // validate HackMD/CodiMD specific header
  180. if (headers['codimd-version'] == null && headers['hackmd-version'] == null) {
  181. const message = 'Connecting to a non-HackMD server.';
  182. logger.error(message);
  183. return res.json(ApiResponse.error(message));
  184. }
  185. try {
  186. // when page is not found
  187. if (status === 404) {
  188. // reset registered data
  189. page = await Page.registerHackmdPage(page, undefined);
  190. // re-invoke
  191. return integrate(req, res);
  192. }
  193. // when redirect
  194. if (status === 302) {
  195. // extract page id on HackMD
  196. const pathnameOnHackmd = new URL(headers.location, hackmdUri).pathname; // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
  197. const pageIdOnHackmd = pathnameOnHackmd.substr(1); // strip the head '/'
  198. page = await Page.registerHackmdPage(page, pageIdOnHackmd);
  199. }
  200. // when page is found
  201. else {
  202. page = await Page.syncRevisionToHackmd(page);
  203. }
  204. const data = {
  205. pageIdOnHackmd: page.pageIdOnHackmd,
  206. revisionIdHackmdSynced: page.revisionHackmdSynced,
  207. hasDraftOnHackmd: page.hasDraftOnHackmd,
  208. };
  209. return res.json(ApiResponse.success(data));
  210. }
  211. catch (err) {
  212. logger.error(err);
  213. return res.json(ApiResponse.error('Integration with HackMD process failed'));
  214. }
  215. };
  216. /**
  217. * @swagger
  218. *
  219. * /hackmd.discard:
  220. * post:
  221. * tags: [Hackmd]
  222. * operationId: discardHackmd
  223. * summary: /hackmd.discard
  224. * description: Discard hackmd
  225. * requestBody:
  226. * content:
  227. * application/json:
  228. * schema:
  229. * properties:
  230. * pageId:
  231. * $ref: '#/components/schemas/Page/properties/_id'
  232. * page:
  233. * $ref: '#/components/schemas/Hackmd'
  234. * responses:
  235. * 200:
  236. * description: Succeeded to integrate HackMD.
  237. * content:
  238. * application/json:
  239. * schema:
  240. * properties:
  241. * ok:
  242. * $ref: '#/components/schemas/V1Response/properties/ok'
  243. * pageIdOnHackmd:
  244. * $ref: '#/components/schemas/Hackmd/properties/pageIdOnHackmd'
  245. * revisionIdHackmdSynced:
  246. * $ref: '#/components/schemas/Hackmd/properties/revisionIdHackmdSynced'
  247. * hasDraftOnHackmd:
  248. * $ref: '#/components/schemas/Hackmd/properties/hasDraftOnHackmd'
  249. * 403:
  250. * $ref: '#/components/responses/403'
  251. * 500:
  252. * $ref: '#/components/responses/500'
  253. */
  254. /**
  255. * POST /_api/hackmd.discard
  256. *
  257. * Create page on HackMD and start to integrate
  258. * @param {object} req
  259. * @param {object} res
  260. */
  261. const discard = async function(req, res) {
  262. let page = req.page;
  263. try {
  264. page = await Page.syncRevisionToHackmd(page);
  265. const data = {
  266. pageIdOnHackmd: page.pageIdOnHackmd,
  267. revisionIdHackmdSynced: page.revisionHackmdSynced,
  268. hasDraftOnHackmd: page.hasDraftOnHackmd,
  269. };
  270. return res.json(ApiResponse.success(data));
  271. }
  272. catch (err) {
  273. logger.error(err);
  274. return res.json(ApiResponse.error('discard process failed'));
  275. }
  276. };
  277. /**
  278. * @swagger
  279. *
  280. * /hackmd.saveOnHackmd:
  281. * post:
  282. * tags: [Hackmd]
  283. * operationId: saveOnHackmd
  284. * summary: /hackmd.saveOnHackmd
  285. * description: Receive when save operation triggered on HackMD
  286. * requestBody:
  287. * content:
  288. * application/json:
  289. * schema:
  290. * properties:
  291. * pageId:
  292. * $ref: '#/components/schemas/Page/properties/_id'
  293. * page:
  294. * $ref: '#/components/schemas/Hackmd'
  295. * responses:
  296. * 200:
  297. * description: Succeeded to receive when save operation triggered on HackMD.
  298. * content:
  299. * application/json:
  300. * schema:
  301. * properties:
  302. * ok:
  303. * $ref: '#/components/schemas/V1Response/properties/ok'
  304. * 403:
  305. * $ref: '#/components/responses/403'
  306. * 500:
  307. * $ref: '#/components/responses/500'
  308. */
  309. /**
  310. * POST /_api/hackmd.saveOnHackmd
  311. *
  312. * receive when save operation triggered on HackMD
  313. * !! This will be invoked many time from many people !!
  314. *
  315. * @param {object} req
  316. * @param {object} res
  317. */
  318. const saveOnHackmd = async function(req, res) {
  319. const page = req.page;
  320. try {
  321. await Page.updateHasDraftOnHackmd(page, true);
  322. pageEvent.emit('saveOnHackmd', page);
  323. return res.json(ApiResponse.success());
  324. }
  325. catch (err) {
  326. logger.error(err);
  327. return res.json(ApiResponse.error('saveOnHackmd process failed'));
  328. }
  329. };
  330. return {
  331. loadAgent,
  332. loadStyles,
  333. validateForApi,
  334. integrate,
  335. discard,
  336. saveOnHackmd,
  337. };
  338. };