config-manager.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import parseISO from 'date-fns/parseISO';
  2. import loggerFactory from '~/utils/logger';
  3. import ConfigModel from '../models/config';
  4. import S2sMessage from '../models/vo/s2s-message';
  5. import ConfigLoader, { ConfigObject } from './config-loader';
  6. import type { S2sMessagingService } from './s2s-messaging/base';
  7. import type { S2sMessageHandlable } from './s2s-messaging/handlable';
  8. const logger = loggerFactory('growi:service:ConfigManager');
  9. const KEYS_FOR_APP_SITE_URL_USES_ONLY_ENV_OPTION = [
  10. 'app:siteUrl',
  11. ];
  12. const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
  13. 'security:passport-local:isEnabled',
  14. ];
  15. const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
  16. 'security:passport-saml:isEnabled',
  17. 'security:passport-saml:entryPoint',
  18. 'security:passport-saml:issuer',
  19. 'security:passport-saml:cert',
  20. ];
  21. const KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION = [
  22. 'app:fileUploadType',
  23. ];
  24. const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
  25. 'gcs:apiKeyJsonPath',
  26. 'gcs:bucket',
  27. 'gcs:uploadNamespace',
  28. ];
  29. export interface ConfigManager {
  30. loadConfigs(): Promise<void>,
  31. getConfig(namespace: string, key: string): any,
  32. getConfigFromDB(namespace: string, key: string): any,
  33. getConfigFromEnvVars(namespace: string, key: string): any,
  34. updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage?: boolean): Promise<void>
  35. removeConfigsInTheSameNamespace(namespace: string, configKeys: string[], withoutPublishingS2sMessage?: boolean): Promise<void>
  36. }
  37. class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
  38. private configLoader: ConfigLoader = new ConfigLoader();
  39. private s2sMessagingService?: S2sMessagingService;
  40. private configObject: ConfigObject = { fromDB: null, fromEnvVars: null };
  41. private configKeys: any[] = [];
  42. private lastLoadedAt?: Date;
  43. private get isInitialized() {
  44. return this.lastLoadedAt != null;
  45. }
  46. private validateInitialized() {
  47. if (!this.isInitialized) {
  48. throw new Error('The config data has not loaded yet.');
  49. }
  50. }
  51. /**
  52. * load configs from the database and the environment variables
  53. */
  54. async loadConfigs(): Promise<void> {
  55. this.configObject = await this.configLoader.load();
  56. logger.debug('ConfigManager#loadConfigs', this.configObject);
  57. // cache all config keys
  58. this.reloadConfigKeys();
  59. this.lastLoadedAt = new Date();
  60. }
  61. /**
  62. * generate an array of config keys from this.configObject
  63. */
  64. private getConfigKeys() {
  65. // type: fromDB, fromEnvVars
  66. const types = Object.keys(this.configObject);
  67. let namespaces: string[] = [];
  68. let keys: string[] = [];
  69. for (const type of types) {
  70. if (this.configObject[type] != null) {
  71. // ns: crowi, markdown, notification
  72. namespaces = [...namespaces, ...Object.keys(this.configObject[type])];
  73. }
  74. }
  75. // remove duplicates
  76. namespaces = [...new Set(namespaces)];
  77. for (const type of types) {
  78. for (const ns of namespaces) {
  79. if (this.configObject[type][ns] != null) {
  80. keys = [...keys, ...Object.keys(this.configObject[type][ns])];
  81. }
  82. }
  83. }
  84. // remove duplicates
  85. keys = [...new Set(keys)];
  86. return keys;
  87. }
  88. private reloadConfigKeys() {
  89. this.configKeys = this.getConfigKeys();
  90. }
  91. /**
  92. * get a config specified by namespace & key
  93. *
  94. * Basically, this searches a specified config from configs loaded from the database at first
  95. * and then from configs loaded from the environment variables.
  96. *
  97. * In some case, this search method changes.
  98. *
  99. * the followings are the meanings of each special return value.
  100. * - null: a specified config is not set.
  101. * - undefined: a specified config does not exist.
  102. */
  103. getConfig(namespace, key) {
  104. this.validateInitialized();
  105. let value;
  106. if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
  107. value = this.searchOnlyFromEnvVarConfigs(namespace, key);
  108. }
  109. else {
  110. value = this.defaultSearch(namespace, key);
  111. }
  112. logger.debug(key, value);
  113. return value;
  114. }
  115. /**
  116. * get a config specified by namespace & key from configs loaded from the database
  117. *
  118. * **Do not use this unless absolutely necessary. Use getConfig instead.**
  119. */
  120. getConfigFromDB(namespace, key) {
  121. this.validateInitialized();
  122. return this.searchOnlyFromDBConfigs(namespace, key);
  123. }
  124. /**
  125. * get a config specified by namespace & key from configs loaded from the environment variables
  126. *
  127. * **Do not use this unless absolutely necessary. Use getConfig instead.**
  128. */
  129. getConfigFromEnvVars(namespace, key) {
  130. this.validateInitialized();
  131. return this.searchOnlyFromEnvVarConfigs(namespace, key);
  132. }
  133. /**
  134. * update configs in the same namespace
  135. *
  136. * Specified values are encoded by convertInsertValue.
  137. * In it, an empty string is converted to null that indicates a config is not set.
  138. *
  139. * For example:
  140. * ```
  141. * updateConfigsInTheSameNamespace(
  142. * 'some namespace',
  143. * {
  144. * 'some key 1': 'value 1',
  145. * 'some key 2': 'value 2',
  146. * ...
  147. * }
  148. * );
  149. * ```
  150. */
  151. async updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage = false): Promise<void> {
  152. const queries: any[] = [];
  153. for (const key of Object.keys(configs)) {
  154. queries.push({
  155. updateOne: {
  156. filter: { ns: namespace, key },
  157. update: { ns: namespace, key, value: this.convertInsertValue(configs[key]) },
  158. upsert: true,
  159. },
  160. });
  161. }
  162. await ConfigModel.bulkWrite(queries);
  163. await this.loadConfigs();
  164. // publish updated date after reloading
  165. if (this.s2sMessagingService != null && !withoutPublishingS2sMessage) {
  166. this.publishUpdateMessage();
  167. }
  168. }
  169. async removeConfigsInTheSameNamespace(namespace, configKeys: string[], withoutPublishingS2sMessage?) {
  170. const queries: any[] = [];
  171. for (const key of configKeys) {
  172. queries.push({
  173. deleteOne: {
  174. filter: { ns: namespace, key },
  175. },
  176. });
  177. }
  178. await ConfigModel.bulkWrite(queries);
  179. await this.loadConfigs();
  180. // publish updated date after reloading
  181. if (this.s2sMessagingService != null && !withoutPublishingS2sMessage) {
  182. this.publishUpdateMessage();
  183. }
  184. }
  185. /**
  186. * return whether the specified namespace/key should be retrieved only from env vars
  187. */
  188. private shouldSearchedFromEnvVarsOnly(namespace, key) {
  189. return (namespace === 'crowi' && (
  190. // siteUrl
  191. (
  192. KEYS_FOR_APP_SITE_URL_USES_ONLY_ENV_OPTION.includes(key)
  193. && this.defaultSearch('crowi', 'app:siteUrl:useOnlyEnvVars')
  194. )
  195. // local strategy
  196. || (
  197. KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION.includes(key)
  198. && this.defaultSearch('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions')
  199. )
  200. // saml strategy
  201. || (
  202. KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)
  203. && this.defaultSearch('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')
  204. )
  205. // file upload option
  206. || (
  207. KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION.includes(key)
  208. && this.searchOnlyFromEnvVarConfigs('crowi', 'app:useOnlyEnvVarForFileUploadType')
  209. )
  210. // gcs option
  211. || (
  212. KEYS_FOR_GCS_USE_ONLY_ENV_OPTION.includes(key)
  213. && this.searchOnlyFromEnvVarConfigs('crowi', 'gcs:useOnlyEnvVarsForSomeOptions')
  214. )
  215. ));
  216. }
  217. /*
  218. * All of the methods below are private APIs.
  219. */
  220. /**
  221. * search a specified config from configs loaded from the database at first
  222. * and then from configs loaded from the environment variables
  223. */
  224. private defaultSearch(namespace, key) {
  225. // does not exist neither in db nor in env vars
  226. if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
  227. logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
  228. return undefined;
  229. }
  230. // only exists in db
  231. if (this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
  232. logger.debug(`${namespace}.${key} only exists in db`);
  233. return this.configObject.fromDB[namespace][key];
  234. }
  235. // only exists env vars
  236. if (!this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key)) {
  237. logger.debug(`${namespace}.${key} only exists in env vars`);
  238. return this.configObject.fromEnvVars[namespace][key];
  239. }
  240. // exists both in db and in env vars [db > env var]
  241. if (this.configExistsInDB(namespace, key) && this.configExistsInEnvVars(namespace, key)) {
  242. if (this.configObject.fromDB[namespace][key] !== null) {
  243. logger.debug(`${namespace}.${key} exists both in db and in env vars. loaded from db`);
  244. return this.configObject.fromDB[namespace][key];
  245. }
  246. /* eslint-disable-next-line no-else-return */
  247. else {
  248. logger.debug(`${namespace}.${key} exists both in db and in env vars. loaded from env vars`);
  249. return this.configObject.fromEnvVars[namespace][key];
  250. }
  251. }
  252. }
  253. /**
  254. * search a specified config from configs loaded from the database
  255. */
  256. private searchOnlyFromDBConfigs(namespace, key) {
  257. if (!this.configExistsInDB(namespace, key)) {
  258. return undefined;
  259. }
  260. return this.configObject.fromDB[namespace][key];
  261. }
  262. /**
  263. * search a specified config from configs loaded from the environment variables
  264. */
  265. private searchOnlyFromEnvVarConfigs(namespace, key) {
  266. if (!this.configExistsInEnvVars(namespace, key)) {
  267. return undefined;
  268. }
  269. return this.configObject.fromEnvVars[namespace][key];
  270. }
  271. /**
  272. * check whether a specified config exists in configs loaded from the database
  273. */
  274. private configExistsInDB(namespace, key) {
  275. if (this.configObject.fromDB[namespace] === undefined) {
  276. return false;
  277. }
  278. return this.configObject.fromDB[namespace][key] !== undefined;
  279. }
  280. /**
  281. * check whether a specified config exists in configs loaded from the environment variables
  282. */
  283. private configExistsInEnvVars(namespace, key) {
  284. if (this.configObject.fromEnvVars[namespace] === undefined) {
  285. return false;
  286. }
  287. return this.configObject.fromEnvVars[namespace][key] !== undefined;
  288. }
  289. private convertInsertValue(value) {
  290. return JSON.stringify(value === '' ? null : value);
  291. }
  292. /**
  293. * Set S2sMessagingServiceDelegator instance
  294. * @param s2sMessagingService
  295. */
  296. setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
  297. this.s2sMessagingService = s2sMessagingService;
  298. }
  299. async publishUpdateMessage() {
  300. const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
  301. try {
  302. await this.s2sMessagingService?.publish(s2sMessage);
  303. }
  304. catch (e) {
  305. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  306. }
  307. }
  308. /**
  309. * @inheritdoc
  310. */
  311. shouldHandleS2sMessage(s2sMessage) {
  312. const { eventName, updatedAt } = s2sMessage;
  313. if (eventName !== 'configUpdated' || updatedAt == null) {
  314. return false;
  315. }
  316. return this.lastLoadedAt == null || this.lastLoadedAt < parseISO(s2sMessage.updatedAt);
  317. }
  318. /**
  319. * @inheritdoc
  320. */
  321. async handleS2sMessage(s2sMessage) {
  322. logger.info('Reload configs by pubsub notification');
  323. return this.loadConfigs();
  324. }
  325. }
  326. // export the singleton instance
  327. export const configManager = new ConfigManagerImpl();