site.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { generateError, generateSuccess } from '../../helpers/graph.mjs'
  2. import _ from 'lodash-es'
  3. import CleanCSS from 'clean-css'
  4. import path from 'node:path'
  5. import fs from 'fs-extra'
  6. import { v4 as uuid } from 'uuid'
  7. export default {
  8. Query: {
  9. async sites () {
  10. const sites = await WIKI.db.sites.query().orderBy('hostname')
  11. return sites.map(s => ({
  12. ...s.config,
  13. id: s.id,
  14. hostname: s.hostname,
  15. isEnabled: s.isEnabled,
  16. pageExtensions: s.config.pageExtensions.join(', ')
  17. }))
  18. },
  19. async siteById (obj, args) {
  20. const site = await WIKI.db.sites.query().findById(args.id)
  21. return site ? {
  22. ...site.config,
  23. id: site.id,
  24. hostname: site.hostname,
  25. isEnabled: site.isEnabled,
  26. pageExtensions: site.config.pageExtensions.join(', ')
  27. } : null
  28. },
  29. async siteByHostname (obj, args) {
  30. let site = await WIKI.db.sites.query().where({
  31. hostname: args.hostname
  32. }).first()
  33. if (!site && !args.exact) {
  34. site = await WIKI.db.sites.query().where({
  35. hostname: '*'
  36. }).first()
  37. }
  38. return site ? {
  39. ...site.config,
  40. id: site.id,
  41. hostname: site.hostname,
  42. isEnabled: site.isEnabled,
  43. pageExtensions: site.config.pageExtensions.join(', ')
  44. } : null
  45. }
  46. },
  47. Mutation: {
  48. /**
  49. * CREATE SITE
  50. */
  51. async createSite (obj, args, context) {
  52. try {
  53. if (!WIKI.auth.checkAccess(context.req.user, ['write:sites', 'manage:sites'])) {
  54. throw new Error('ERR_FORBIDDEN')
  55. }
  56. // -> Validate inputs
  57. if (!args.hostname || args.hostname.length < 1 || !/^(\\*)|([a-z0-9\-.:]+)$/.test(args.hostname)) {
  58. throw new WIKI.Error.Custom('SiteCreateInvalidHostname', 'Invalid Site Hostname')
  59. }
  60. if (!args.title || args.title.length < 1 || !/^[^<>"]+$/.test(args.title)) {
  61. throw new WIKI.Error.Custom('SiteCreateInvalidTitle', 'Invalid Site Title')
  62. }
  63. // -> Check for duplicate catch-all
  64. if (args.hostname === '*') {
  65. const site = await WIKI.db.sites.query().where({
  66. hostname: args.hostname
  67. }).first()
  68. if (site) {
  69. throw new WIKI.Error.Custom('SiteCreateDuplicateCatchAll', 'A site with a catch-all hostname already exists! Cannot have 2 catch-all hostnames.')
  70. }
  71. }
  72. // -> Create site
  73. const newSite = await WIKI.db.sites.createSite(args.hostname, {
  74. title: args.title
  75. })
  76. return {
  77. operation: generateSuccess('Site created successfully'),
  78. site: newSite
  79. }
  80. } catch (err) {
  81. WIKI.logger.warn(err)
  82. return generateError(err)
  83. }
  84. },
  85. /**
  86. * UPDATE SITE
  87. */
  88. async updateSite (obj, args, context) {
  89. try {
  90. if (!WIKI.auth.checkAccess(context.req.user, ['manage:sites'])) {
  91. throw new Error('ERR_FORBIDDEN')
  92. }
  93. // -> Load site
  94. const site = await WIKI.db.sites.query().findById(args.id)
  95. if (!site) {
  96. throw new WIKI.Error.Custom('SiteInvalidId', 'Invalid Site ID')
  97. }
  98. // -> Check for bad input
  99. if (_.has(args.patch, 'hostname') && _.trim(args.patch.hostname).length < 1) {
  100. throw new WIKI.Error.Custom('SiteInvalidHostname', 'Hostname is invalid.')
  101. }
  102. // -> Check for duplicate catch-all
  103. if (args.patch.hostname === '*' && site.hostname !== '*') {
  104. const dupSite = await WIKI.db.sites.query().where({ hostname: '*' }).first()
  105. if (dupSite) {
  106. throw new WIKI.Error.Custom('SiteUpdateDuplicateCatchAll', `Site ${dupSite.config.title} with a catch-all hostname already exists! Cannot have 2 catch-all hostnames.`)
  107. }
  108. }
  109. // -> Format Code
  110. if (args.patch?.theme?.injectCSS) {
  111. args.patch.theme.injectCSS = new CleanCSS({ inline: false }).minify(args.patch.theme.injectCSS).styles
  112. }
  113. // -> Format Page Extensions
  114. if (args.patch?.pageExtensions) {
  115. args.patch.pageExtensions = args.patch.pageExtensions.split(',').map(ext => ext.trim().toLowerCase()).filter(ext => ext.length > 0)
  116. }
  117. // -> Update site
  118. await WIKI.db.sites.updateSite(args.id, {
  119. hostname: args.patch.hostname ?? site.hostname,
  120. isEnabled: args.patch.isEnabled ?? site.isEnabled,
  121. config: _.defaultsDeep(_.omit(args.patch, ['hostname', 'isEnabled']), site.config)
  122. })
  123. return {
  124. operation: generateSuccess('Site updated successfully')
  125. }
  126. } catch (err) {
  127. WIKI.logger.warn(err)
  128. return generateError(err)
  129. }
  130. },
  131. /**
  132. * DELETE SITE
  133. */
  134. async deleteSite (obj, args, context) {
  135. try {
  136. if (!WIKI.auth.checkAccess(context.req.user, ['manage:sites'])) {
  137. throw new Error('ERR_FORBIDDEN')
  138. }
  139. // -> Ensure site isn't last one
  140. const sitesCount = await WIKI.db.sites.query().count('id').first()
  141. if (sitesCount?.count && _.toNumber(sitesCount?.count) <= 1) {
  142. throw new WIKI.Error.Custom('SiteDeleteLastSite', 'Cannot delete the last site. At least 1 site must exists at all times.')
  143. }
  144. // -> Delete site
  145. await WIKI.db.sites.deleteSite(args.id)
  146. return {
  147. operation: generateSuccess('Site deleted successfully')
  148. }
  149. } catch (err) {
  150. WIKI.logger.warn(err)
  151. return generateError(err)
  152. }
  153. },
  154. /**
  155. * UPLOAD LOGO
  156. */
  157. async uploadSiteLogo (obj, args, context) {
  158. try {
  159. if (!WIKI.auth.checkAccess(context.req.user, ['manage:sites'])) {
  160. throw new Error('ERR_FORBIDDEN')
  161. }
  162. const { filename, mimetype, createReadStream } = await args.image
  163. WIKI.logger.info(`Processing site logo ${filename} of type ${mimetype}...`)
  164. if (!WIKI.extensions.ext.sharp.isInstalled) {
  165. throw new Error('This feature requires the Sharp extension but it is not installed.')
  166. }
  167. if (!['.svg', '.png', '.jpg', 'webp', '.gif'].some(s => filename.endsWith(s))) {
  168. throw new Error('Invalid File Extension. Must be svg, png, jpg, webp or gif.')
  169. }
  170. const destFormat = mimetype.startsWith('image/svg') ? 'svg' : 'png'
  171. const destFolder = path.resolve(
  172. process.cwd(),
  173. WIKI.config.dataPath,
  174. `assets`
  175. )
  176. const destPath = path.join(destFolder, `logo-${args.id}.${destFormat}`)
  177. await fs.ensureDir(destFolder)
  178. // -> Resize
  179. await WIKI.extensions.ext.sharp.resize({
  180. format: destFormat,
  181. inputStream: createReadStream(),
  182. outputPath: destPath,
  183. height: 72
  184. })
  185. // -> Save logo meta to DB
  186. const site = await WIKI.db.sites.query().findById(args.id)
  187. if (!site.config.assets.logo) {
  188. site.config.assets.logo = uuid()
  189. }
  190. site.config.assets.logoExt = destFormat
  191. await WIKI.db.sites.query().findById(args.id).patch({ config: site.config })
  192. await WIKI.db.sites.reloadCache()
  193. // -> Save image data to DB
  194. const imgBuffer = await fs.readFile(destPath)
  195. await WIKI.db.knex('assets').insert({
  196. id: site.config.assets.logo,
  197. fileName: `_logo.${destFormat}`,
  198. fileExt: `.${destFormat}`,
  199. isSystem: true,
  200. kind: 'image',
  201. mimeType: (destFormat === 'svg') ? 'image/svg' : 'image/png',
  202. fileSize: Math.ceil(imgBuffer.byteLength / 1024),
  203. data: imgBuffer,
  204. authorId: context.req.user.id,
  205. siteId: site.id
  206. }).onConflict('id').merge()
  207. WIKI.logger.info('New site logo processed successfully.')
  208. return {
  209. operation: generateSuccess('Site logo uploaded successfully')
  210. }
  211. } catch (err) {
  212. WIKI.logger.warn(err)
  213. return generateError(err)
  214. }
  215. },
  216. /**
  217. * UPLOAD FAVICON
  218. */
  219. async uploadSiteFavicon (obj, args, context) {
  220. try {
  221. if (!WIKI.auth.checkAccess(context.req.user, ['manage:sites'])) {
  222. throw new Error('ERR_FORBIDDEN')
  223. }
  224. const { filename, mimetype, createReadStream } = await args.image
  225. WIKI.logger.info(`Processing site favicon ${filename} of type ${mimetype}...`)
  226. if (!WIKI.extensions.ext.sharp.isInstalled) {
  227. throw new Error('This feature requires the Sharp extension but it is not installed.')
  228. }
  229. if (!['.svg', '.png', '.jpg', '.webp', '.gif'].some(s => filename.endsWith(s))) {
  230. throw new Error('Invalid File Extension. Must be svg, png, jpg, webp or gif.')
  231. }
  232. const destFormat = mimetype.startsWith('image/svg') ? 'svg' : 'png'
  233. const destFolder = path.resolve(
  234. process.cwd(),
  235. WIKI.config.dataPath,
  236. `assets`
  237. )
  238. const destPath = path.join(destFolder, `favicon-${args.id}.${destFormat}`)
  239. await fs.ensureDir(destFolder)
  240. // -> Resize
  241. await WIKI.extensions.ext.sharp.resize({
  242. format: destFormat,
  243. inputStream: createReadStream(),
  244. outputPath: destPath,
  245. width: 64,
  246. height: 64
  247. })
  248. // -> Save favicon meta to DB
  249. const site = await WIKI.db.sites.query().findById(args.id)
  250. if (!site.config.assets.favicon) {
  251. site.config.assets.favicon = uuid()
  252. }
  253. site.config.assets.faviconExt = destFormat
  254. await WIKI.db.sites.query().findById(args.id).patch({ config: site.config })
  255. await WIKI.db.sites.reloadCache()
  256. // -> Save image data to DB
  257. const imgBuffer = await fs.readFile(destPath)
  258. await WIKI.db.knex('assets').insert({
  259. id: site.config.assets.favicon,
  260. fileName: `_favicon.${destFormat}`,
  261. fileExt: `.${destFormat}`,
  262. isSystem: true,
  263. kind: 'image',
  264. mimeType: (destFormat === 'svg') ? 'image/svg' : 'image/png',
  265. fileSize: Math.ceil(imgBuffer.byteLength / 1024),
  266. data: imgBuffer,
  267. authorId: context.req.user.id,
  268. siteId: site.id
  269. }).onConflict('id').merge()
  270. WIKI.logger.info('New site favicon processed successfully.')
  271. return {
  272. operation: generateSuccess('Site favicon uploaded successfully')
  273. }
  274. } catch (err) {
  275. WIKI.logger.warn(err)
  276. return generateError(err)
  277. }
  278. },
  279. /**
  280. * UPLOAD LOGIN BG
  281. */
  282. async uploadSiteLoginBg (obj, args, context) {
  283. try {
  284. if (!WIKI.auth.checkAccess(context.req.user, ['manage:sites'])) {
  285. throw new Error('ERR_FORBIDDEN')
  286. }
  287. const { filename, mimetype, createReadStream } = await args.image
  288. WIKI.logger.info(`Processing site login bg ${filename} of type ${mimetype}...`)
  289. if (!WIKI.extensions.ext.sharp.isInstalled) {
  290. throw new Error('This feature requires the Sharp extension but it is not installed.')
  291. }
  292. if (!['.png', '.jpg', '.webp'].some(s => filename.endsWith(s))) {
  293. throw new Error('Invalid File Extension. Must be png, jpg or webp.')
  294. }
  295. const destFolder = path.resolve(
  296. process.cwd(),
  297. WIKI.config.dataPath,
  298. `assets`
  299. )
  300. const destPath = path.join(destFolder, `loginbg-${args.id}.jpg`)
  301. await fs.ensureDir(destFolder)
  302. // -> Resize
  303. await WIKI.extensions.ext.sharp.resize({
  304. format: 'jpg',
  305. inputStream: createReadStream(),
  306. outputPath: destPath,
  307. width: 1920
  308. })
  309. // -> Save login bg meta to DB
  310. const site = await WIKI.db.sites.query().findById(args.id)
  311. if (!site.config.assets.loginBg) {
  312. site.config.assets.loginBg = uuid()
  313. await WIKI.db.sites.query().findById(args.id).patch({ config: site.config })
  314. await WIKI.db.sites.reloadCache()
  315. }
  316. // -> Save image data to DB
  317. const imgBuffer = await fs.readFile(destPath)
  318. await WIKI.db.knex('assets').insert({
  319. id: site.config.assets.loginBg,
  320. filename: '_loginbg.jpg',
  321. hash: '_loginbg',
  322. ext: '.jpg',
  323. isSystem: true,
  324. kind: 'image',
  325. mime: 'image/jpg',
  326. fileSize: Math.ceil(imgBuffer.byteLength / 1024),
  327. data: imgBuffer,
  328. authorId: context.req.user.id,
  329. siteId: site.id
  330. }).onConflict('id').merge()
  331. WIKI.logger.info('New site login bg processed successfully.')
  332. return {
  333. operation: generateSuccess('Site login bg uploaded successfully')
  334. }
  335. } catch (err) {
  336. WIKI.logger.warn(err)
  337. return generateError(err)
  338. }
  339. }
  340. },
  341. SiteLocales: {
  342. async active (obj, args, context) {
  343. return obj.active.map(l => WIKI.cache.get(`locale:${l}`))
  344. }
  345. }
  346. }