hackmd.js 11 KB

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