config-manager.ts 12 KB

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