import { REQUEST_TIMEOUT_FOR_PTOG, type IChannelOptionalId } from '@growi/slack'; import { getSupportedGrowiActionsRegExp } from '@growi/slack/dist/utils/get-supported-growi-actions-regexps'; import { permissionParser } from '@growi/slack/dist/utils/permission-parser'; import { Inject, Service } from '@tsed/di'; import axios from 'axios'; import { addHours } from 'date-fns/addHours'; import { Relation, PermissionSettingsInterface } from '~/entities/relation'; import { RelationRepository } from '~/repositories/relation'; import loggerFactory from '~/utils/logger'; const logger = loggerFactory('slackbot-proxy:services:RelationsService'); type CheckPermissionForInteractionsResults = { allowedRelations:Relation[], disallowedGrowiUrls:Set, commandName:string, rejectedResults:PromiseRejectedResult[] } type CheckEachRelationResult = { allowedRelation:Relation|null, disallowedGrowiUrl:string|null, eachRelationCommandName:string, } @Service() export class RelationsService { @Inject() relationRepository: RelationRepository; async resetAllExpiredAtCommands(): Promise { await this.relationRepository.update({}, { expiredAtCommands: new Date('2000-01-01') }); } private async getSupportedGrowiCommands(relation:Relation):Promise { // generate API URL const url = new URL('/_api/v3/slack-integration/supported-commands', relation.growiUri); return axios.get(url.toString(), { headers: { 'x-growi-ptog-tokens': relation.tokenPtoG, }, timeout: REQUEST_TIMEOUT_FOR_PTOG, }); } private async syncSupportedGrowiCommands(relation:Relation): Promise { const res = await this.getSupportedGrowiCommands(relation); // support both of v4.4.x and v4.5.x // see: https://redmine.weseek.co.jp/issues/82985 const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data ?? res.data; if (relation !== null) { relation.permissionsForBroadcastUseCommands = permissionsForBroadcastUseCommands; relation.permissionsForSingleUseCommands = permissionsForSingleUseCommands; relation.expiredAtCommands = addHours(new Date(), 48); return this.relationRepository.save(relation); } throw Error('No relation exists.'); } private async syncRelation(relation: Relation): Promise { // TODO use assert (relation != null) const isDataNull = relation.permissionsForBroadcastUseCommands == null || relation.permissionsForBroadcastUseCommands == null; const distanceMillisecondsToExpiredAt = relation.getDistanceInMillisecondsToExpiredAt(new Date()); const isExpired = distanceMillisecondsToExpiredAt < 0; if (isDataNull || isExpired) { return this.syncSupportedGrowiCommands(relation); } // 24 hours const isLimitUnder24Hours = distanceMillisecondsToExpiredAt < 24 * 60 * 60 * 1000; if (isLimitUnder24Hours) { this.syncSupportedGrowiCommands(relation); } return relation; } private isPermitted(permissionSettings: PermissionSettingsInterface, growiCommandType: string, channel: IChannelOptionalId): boolean { // TODO assert (permissionSettings != null) const permissionForCommand = permissionSettings[growiCommandType]; return permissionParser(permissionForCommand, channel); } async isPermissionsForSingleUseCommands(relation: Relation, growiCommandType: string, channel: IChannelOptionalId): Promise { // TODO assert (relation != null) if (relation == null) { return false; } let relationToEval = relation; try { relationToEval = await this.syncRelation(relation); } catch (err) { logger.error('failed to sync', err); return false; } // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success return this.isPermitted(relationToEval.permissionsForSingleUseCommands, growiCommandType, channel); } async isPermissionsUseBroadcastCommands(relation: Relation, growiCommandType: string, channel: IChannelOptionalId):Promise { // TODO assert (relation != null) if (relation == null) { return false; } let relationToEval = relation; try { relationToEval = await this.syncRelation(relation); } catch (err) { logger.error('failed to sync', err); return false; } // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success return this.isPermitted(relationToEval.permissionsForBroadcastUseCommands, growiCommandType, channel); } async checkPermissionForInteractions( relations: Relation[], actionId: string, callbackId: string, channel: IChannelOptionalId, ):Promise { const allowedRelations:Relation[] = []; const disallowedGrowiUrls:Set = new Set(); let commandName = ''; const results = await Promise.allSettled(relations.map((relation) => { const relationResult = this.checkEachRelation(relation, actionId, callbackId, channel); const { allowedRelation, disallowedGrowiUrl, eachRelationCommandName } = relationResult; if (allowedRelation != null) { allowedRelations.push(allowedRelation); } if (disallowedGrowiUrl != null) { disallowedGrowiUrls.add(disallowedGrowiUrl); } commandName = eachRelationCommandName; return relationResult; })); // Pick up only a relation which status is "rejected" in results. Like bellow const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); return { allowedRelations, disallowedGrowiUrls, commandName, rejectedResults, }; } checkEachRelation(relation:Relation, actionId:string, callbackId:string, channel: IChannelOptionalId): CheckEachRelationResult { let allowedRelation:Relation|null = null; let disallowedGrowiUrl:string|null = null; let eachRelationCommandName = ''; let permissionForInteractions:boolean|string[]; const singleUse = Object.keys(relation.permissionsForSingleUseCommands); const broadCastUse = Object.keys(relation.permissionsForBroadcastUseCommands); [...singleUse, ...broadCastUse].forEach(async(tempCommandName) => { // ex. search OR search:handlerName const commandRegExp = getSupportedGrowiActionsRegExp(tempCommandName); // skip this forEach loop if the requested command is not in permissionsForBroadcastUseCommands and permissionsForSingleUseCommands if (!commandRegExp.test(actionId) && !commandRegExp.test(callbackId)) { return; } eachRelationCommandName = tempCommandName; // case: singleUse permissionForInteractions = relation.permissionsForSingleUseCommands[tempCommandName]; // case: broadcastUse if (permissionForInteractions == null) { permissionForInteractions = relation.permissionsForBroadcastUseCommands[tempCommandName]; } if (permissionForInteractions === true) { allowedRelation = relation; return; } // check permission at channel level if (Array.isArray(permissionForInteractions)) { if (permissionForInteractions.includes(channel.name)) { allowedRelation = relation; return; } if (channel.id == null) return; if (permissionForInteractions.includes(channel.id)) { allowedRelation = relation; return; } } disallowedGrowiUrl = relation.growiUri; }); return { allowedRelation, disallowedGrowiUrl, eachRelationCommandName }; } }