hackmd.js 11 KB

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