RelationsService.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import {
  2. type IChannelOptionalId,
  3. REQUEST_TIMEOUT_FOR_PTOG,
  4. } from '@growi/slack';
  5. import { getSupportedGrowiActionsRegExp } from '@growi/slack/dist/utils/get-supported-growi-actions-regexps';
  6. import { permissionParser } from '@growi/slack/dist/utils/permission-parser';
  7. import { Inject, Service } from '@tsed/di';
  8. import axios from 'axios';
  9. import { addHours } from 'date-fns/addHours';
  10. import { PermissionSettingsInterface, Relation } from '~/entities/relation';
  11. import { RelationRepository } from '~/repositories/relation';
  12. import loggerFactory from '~/utils/logger';
  13. const logger = loggerFactory('slackbot-proxy:services:RelationsService');
  14. type CheckPermissionForInteractionsResults = {
  15. allowedRelations: Relation[];
  16. disallowedGrowiUrls: Set<string>;
  17. commandName: string;
  18. rejectedResults: PromiseRejectedResult[];
  19. };
  20. type CheckEachRelationResult = {
  21. allowedRelation: Relation | null;
  22. disallowedGrowiUrl: string | null;
  23. eachRelationCommandName: string;
  24. };
  25. @Service()
  26. export class RelationsService {
  27. @Inject()
  28. relationRepository: RelationRepository;
  29. async resetAllExpiredAtCommands(): Promise<void> {
  30. await this.relationRepository.update(
  31. {},
  32. { expiredAtCommands: new Date('2000-01-01') },
  33. );
  34. }
  35. private async getSupportedGrowiCommands(relation: Relation): Promise<any> {
  36. // generate API URL
  37. const url = new URL(
  38. '/_api/v3/slack-integration/supported-commands',
  39. relation.growiUri,
  40. );
  41. return axios.get(url.toString(), {
  42. headers: {
  43. 'x-growi-ptog-tokens': relation.tokenPtoG,
  44. },
  45. timeout: REQUEST_TIMEOUT_FOR_PTOG,
  46. });
  47. }
  48. private async syncSupportedGrowiCommands(
  49. relation: Relation,
  50. ): Promise<Relation> {
  51. const res = await this.getSupportedGrowiCommands(relation);
  52. // support both of v4.4.x and v4.5.x
  53. // see: https://redmine.weseek.co.jp/issues/82985
  54. const {
  55. permissionsForBroadcastUseCommands,
  56. permissionsForSingleUseCommands,
  57. } = res.data.data ?? res.data;
  58. if (relation !== null) {
  59. relation.permissionsForBroadcastUseCommands =
  60. permissionsForBroadcastUseCommands;
  61. relation.permissionsForSingleUseCommands =
  62. permissionsForSingleUseCommands;
  63. relation.expiredAtCommands = addHours(new Date(), 48);
  64. return this.relationRepository.save(relation);
  65. }
  66. throw Error('No relation exists.');
  67. }
  68. private async syncRelation(relation: Relation): Promise<Relation> {
  69. // TODO use assert (relation != null)
  70. const isDataNull =
  71. relation.permissionsForBroadcastUseCommands == null ||
  72. relation.permissionsForBroadcastUseCommands == null;
  73. const distanceMillisecondsToExpiredAt =
  74. relation.getDistanceInMillisecondsToExpiredAt(new Date());
  75. const isExpired = distanceMillisecondsToExpiredAt < 0;
  76. if (isDataNull || isExpired) {
  77. return this.syncSupportedGrowiCommands(relation);
  78. }
  79. // 24 hours
  80. const isLimitUnder24Hours =
  81. distanceMillisecondsToExpiredAt < 24 * 60 * 60 * 1000;
  82. if (isLimitUnder24Hours) {
  83. this.syncSupportedGrowiCommands(relation);
  84. }
  85. return relation;
  86. }
  87. private isPermitted(
  88. permissionSettings: PermissionSettingsInterface,
  89. growiCommandType: string,
  90. channel: IChannelOptionalId,
  91. ): boolean {
  92. // TODO assert (permissionSettings != null)
  93. const permissionForCommand = permissionSettings[growiCommandType];
  94. return permissionParser(permissionForCommand, channel);
  95. }
  96. async isPermissionsForSingleUseCommands(
  97. relation: Relation,
  98. growiCommandType: string,
  99. channel: IChannelOptionalId,
  100. ): Promise<boolean> {
  101. // TODO assert (relation != null)
  102. if (relation == null) {
  103. return false;
  104. }
  105. let relationToEval = relation;
  106. try {
  107. relationToEval = await this.syncRelation(relation);
  108. } catch (err) {
  109. logger.error('failed to sync', err);
  110. return false;
  111. }
  112. // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success
  113. return this.isPermitted(
  114. relationToEval.permissionsForSingleUseCommands,
  115. growiCommandType,
  116. channel,
  117. );
  118. }
  119. async isPermissionsUseBroadcastCommands(
  120. relation: Relation,
  121. growiCommandType: string,
  122. channel: IChannelOptionalId,
  123. ): Promise<boolean> {
  124. // TODO assert (relation != null)
  125. if (relation == null) {
  126. return false;
  127. }
  128. let relationToEval = relation;
  129. try {
  130. relationToEval = await this.syncRelation(relation);
  131. } catch (err) {
  132. logger.error('failed to sync', err);
  133. return false;
  134. }
  135. // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success
  136. return this.isPermitted(
  137. relationToEval.permissionsForBroadcastUseCommands,
  138. growiCommandType,
  139. channel,
  140. );
  141. }
  142. async checkPermissionForInteractions(
  143. relations: Relation[],
  144. actionId: string,
  145. callbackId: string,
  146. channel: IChannelOptionalId,
  147. ): Promise<CheckPermissionForInteractionsResults> {
  148. const allowedRelations: Relation[] = [];
  149. const disallowedGrowiUrls: Set<string> = new Set();
  150. let commandName = '';
  151. const results = await Promise.allSettled(
  152. relations.map((relation) => {
  153. const relationResult = this.checkEachRelation(
  154. relation,
  155. actionId,
  156. callbackId,
  157. channel,
  158. );
  159. const { allowedRelation, disallowedGrowiUrl, eachRelationCommandName } =
  160. relationResult;
  161. if (allowedRelation != null) {
  162. allowedRelations.push(allowedRelation);
  163. }
  164. if (disallowedGrowiUrl != null) {
  165. disallowedGrowiUrls.add(disallowedGrowiUrl);
  166. }
  167. commandName = eachRelationCommandName;
  168. return relationResult;
  169. }),
  170. );
  171. // Pick up only a relation which status is "rejected" in results. Like bellow
  172. const rejectedResults: PromiseRejectedResult[] = results.filter(
  173. (result): result is PromiseRejectedResult => result.status === 'rejected',
  174. );
  175. return {
  176. allowedRelations,
  177. disallowedGrowiUrls,
  178. commandName,
  179. rejectedResults,
  180. };
  181. }
  182. checkEachRelation(
  183. relation: Relation,
  184. actionId: string,
  185. callbackId: string,
  186. channel: IChannelOptionalId,
  187. ): CheckEachRelationResult {
  188. let allowedRelation: Relation | null = null;
  189. let disallowedGrowiUrl: string | null = null;
  190. let eachRelationCommandName = '';
  191. let permissionForInteractions: boolean | string[];
  192. const singleUse = Object.keys(relation.permissionsForSingleUseCommands);
  193. const broadCastUse = Object.keys(
  194. relation.permissionsForBroadcastUseCommands,
  195. );
  196. [...singleUse, ...broadCastUse].forEach(async (tempCommandName) => {
  197. // ex. search OR search:handlerName
  198. const commandRegExp = getSupportedGrowiActionsRegExp(tempCommandName);
  199. // skip this forEach loop if the requested command is not in permissionsForBroadcastUseCommands and permissionsForSingleUseCommands
  200. if (!commandRegExp.test(actionId) && !commandRegExp.test(callbackId)) {
  201. return;
  202. }
  203. eachRelationCommandName = tempCommandName;
  204. // case: singleUse
  205. permissionForInteractions =
  206. relation.permissionsForSingleUseCommands[tempCommandName];
  207. // case: broadcastUse
  208. if (permissionForInteractions == null) {
  209. permissionForInteractions =
  210. relation.permissionsForBroadcastUseCommands[tempCommandName];
  211. }
  212. if (permissionForInteractions === true) {
  213. allowedRelation = relation;
  214. return;
  215. }
  216. // check permission at channel level
  217. if (Array.isArray(permissionForInteractions)) {
  218. if (permissionForInteractions.includes(channel.name)) {
  219. allowedRelation = relation;
  220. return;
  221. }
  222. if (channel.id == null) return;
  223. if (permissionForInteractions.includes(channel.id)) {
  224. allowedRelation = relation;
  225. return;
  226. }
  227. }
  228. disallowedGrowiUrl = relation.growiUri;
  229. });
  230. return { allowedRelation, disallowedGrowiUrl, eachRelationCommandName };
  231. }
  232. }