Skip to content

Commit e0121f7

Browse files
committed
feat: controller method decorators (#100)
1 parent 43e1962 commit e0121f7

20 files changed

+343
-371
lines changed

config/tslint.cc.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@
5151
],
5252
"new-parens": true,
5353
"no-angle-bracket-type-assertion": true,
54+
"no-any": true,
5455
"no-arg": true,
5556
"no-bitwise": false,
57+
"no-boolean-literal-compare": true,
5658
"no-conditional-assignment": true,
5759
"no-console": true,
5860
"no-construct": true,

src/controller/AccountController.ts

Lines changed: 41 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isNil } from 'lodash';
22
import { Inject } from 'noicejs';
33
import { Connection, In, Repository } from 'typeorm';
44

5+
import { CheckRBAC, HandleNoun, HandleVerb } from 'src/controller';
56
import { BaseController, ErrorReplyType } from 'src/controller/BaseController';
67
import { createCompletion } from 'src/controller/CompletionController';
78
import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller';
@@ -55,83 +56,36 @@ export class AccountController extends BaseController<AccountControllerData> imp
5556
this.userRepository = this.storage.getCustomRepository(UserRepository);
5657
}
5758

58-
public async handle(cmd: Command): Promise<void> {
59-
switch (cmd.noun) {
60-
case NOUN_ACCOUNT:
61-
return this.handleAccount(cmd);
62-
case NOUN_GRANT:
63-
return this.handleGrant(cmd);
64-
case NOUN_SESSION:
65-
return this.handleSession(cmd);
66-
default:
67-
return this.reply(cmd.context, `unsupported noun: ${cmd.noun}`);
68-
}
69-
}
70-
71-
public async handleAccount(cmd: Command): Promise<void> {
72-
switch (cmd.verb) {
73-
case CommandVerb.Create:
74-
return this.createAccount(cmd);
75-
case CommandVerb.Delete:
76-
return this.deleteAccount(cmd);
77-
default:
78-
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
79-
}
80-
}
81-
82-
public async handleGrant(cmd: Command): Promise<void> {
83-
switch (cmd.verb) {
84-
case CommandVerb.Get:
85-
return this.getGrant(cmd);
86-
case CommandVerb.List:
87-
return this.listGrants(cmd);
88-
default:
89-
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
90-
}
91-
}
92-
93-
public async handleSession(cmd: Command): Promise<void> {
94-
switch (cmd.verb) {
95-
case CommandVerb.Create:
96-
return this.createSession(cmd);
97-
case CommandVerb.Get:
98-
return this.getSession(cmd);
99-
default:
100-
return this.reply(cmd.context, `unsupported verb: ${cmd.verb}`);
101-
}
102-
}
103-
59+
@HandleNoun(NOUN_GRANT)
60+
@HandleVerb(CommandVerb.Get)
61+
@CheckRBAC()
10462
public async getGrant(cmd: Command): Promise<void> {
105-
if (!this.checkGrants(cmd.context, 'grant:get')) {
106-
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
107-
}
108-
10963
const grants = cmd.get('grants');
11064
const results = grants.map((p) => {
11165
return `\`${p}: ${cmd.context.checkGrants([p])}\``;
11266
}).join('\n');
11367
return this.reply(cmd.context, results);
11468
}
11569

70+
@HandleNoun(NOUN_GRANT)
71+
@HandleVerb(CommandVerb.List)
72+
@CheckRBAC()
11673
public async listGrants(cmd: Command): Promise<void> {
117-
if (!this.checkGrants(cmd.context, 'grant:list')) {
118-
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
119-
}
120-
12174
const grants = cmd.get('grants');
12275
const results = grants.map((p) => {
12376
return `\`${p}: ${cmd.context.listGrants([p])}\``;
12477
}).join('\n');
12578
return this.reply(cmd.context, results);
12679
}
12780

81+
@HandleNoun(NOUN_ACCOUNT)
82+
@HandleVerb(CommandVerb.Create)
12883
public async createAccount(cmd: Command): Promise<void> {
129-
if (!this.checkGrants(cmd.context, 'account:create') && !this.data.join.allow) {
84+
if (!this.data.join.allow && !this.checkGrants(cmd.context, 'account:create')) {
13085
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
13186
}
13287

13388
const name = cmd.getHeadOrDefault('name', cmd.context.name);
134-
13589
if (await this.userRepository.count({
13690
name,
13791
})) {
@@ -153,63 +107,54 @@ export class AccountController extends BaseController<AccountControllerData> imp
153107
return this.reply(cmd.context, `user ${name} joined, sign in token: ${jwt}`);
154108
}
155109

110+
@HandleNoun(NOUN_ACCOUNT)
111+
@HandleVerb(CommandVerb.Delete)
112+
@CheckRBAC()
156113
public async deleteAccount(cmd: Command): Promise<void> {
157-
if (isNil(cmd.context.user)) {
158-
return this.errorReply(cmd.context, ErrorReplyType.SessionMissing);
159-
}
160-
161-
if (!this.checkGrants(cmd.context, 'account:delete')) {
162-
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
163-
}
114+
const user = this.getUserOrFail(cmd.context);
164115

165116
if (cmd.getHeadOrDefault('confirm', 'no') !== 'yes') {
166-
const completion = createCompletion(cmd, 'confirm', `please confirm deleting all tokens for ${cmd.context.user.name}`);
117+
const completion = createCompletion(cmd, 'confirm', `please confirm deleting all tokens for ${user.name}`);
167118
await this.bot.executeCommand(completion);
168119
return;
169120
}
170121

171122
await this.tokenRepository.delete({
172-
subject: cmd.context.user.id,
123+
subject: user.id,
173124
});
174125

175-
const jwt = await this.createToken(cmd.context.user);
176-
return this.reply(cmd.context, `revoked tokens for ${cmd.context.user.name}, new sign in token: ${jwt}`);
126+
const jwt = await this.createToken(user);
127+
return this.reply(cmd.context, `revoked tokens for ${user.name}, new sign in token: ${jwt}`);
177128
}
178129

130+
@HandleNoun(NOUN_SESSION)
131+
@HandleVerb(CommandVerb.Create)
179132
public async createSession(cmd: Command): Promise<void> {
180-
if (isNil(cmd.context.source)) {
181-
return this.reply(cmd.context, 'no source listener with which to create a session');
182-
}
133+
const jwt = cmd.getHead('token');
134+
const token = Token.verify(jwt, this.data.token.secret, {
135+
audience: this.data.token.audience,
136+
issuer: this.data.token.issuer,
137+
});
138+
this.logger.debug({ token }, 'creating session from token');
183139

184-
try {
185-
const jwt = cmd.getHead('token');
186-
const token = Token.verify(jwt, this.data.token.secret, {
187-
audience: this.data.token.audience,
188-
issuer: this.data.token.issuer,
189-
});
190-
this.logger.debug({ token }, 'creating session from token');
191-
192-
const user = await this.userRepository.findOneOrFail({
193-
id: token.sub,
194-
});
195-
await this.userRepository.loadRoles(user);
196-
this.logger.debug({ user }, 'logging in user');
197-
198-
const session = await cmd.context.source.createSession(cmd.context.uid, user);
199-
this.logger.debug({ session, user }, 'created session');
200-
return this.reply(cmd.context, 'created session');
201-
} catch (err) {
202-
this.logger.error(err, 'error creating session');
203-
return this.reply(cmd.context, err.message);
204-
}
140+
const user = await this.userRepository.findOneOrFail({
141+
id: token.sub,
142+
});
143+
await this.userRepository.loadRoles(user);
144+
this.logger.debug({ user }, 'logging in user');
145+
146+
const source = this.getSourceOrFail(cmd.context);
147+
const session = await source.createSession(cmd.context.uid, user);
148+
this.logger.debug({ session, user }, 'created session');
149+
return this.reply(cmd.context, 'created session');
205150
}
206151

152+
@HandleNoun(NOUN_SESSION)
153+
@HandleVerb(CommandVerb.Get)
154+
@CheckRBAC()
207155
public async getSession(cmd: Command): Promise<void> {
208-
if (isNil(cmd.context.source)) {
209-
return this.reply(cmd.context, 'no source listener with which to create a session');
210-
}
211-
212-
const session = cmd.context.source.getSession(cmd.context.uid);
156+
const source = this.getSourceOrFail(cmd.context);
157+
const session = source.getSession(cmd.context.uid);
213158
if (isNil(session)) {
214159
return this.reply(cmd.context, 'cannot get sessions unless logged in');
215160
}

src/controller/BaseController.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { isString } from 'lodash';
2-
import { Inject } from 'noicejs';
1+
import { isNil, isString } from 'lodash';
2+
import { Inject, MissingValueError } from 'noicejs';
33

44
import { BotService } from 'src/BotService';
55
import { Controller, ControllerData, ControllerOptions } from 'src/controller/Controller';
@@ -9,9 +9,13 @@ import { Message } from 'src/entity/Message';
99
import { ServiceModule } from 'src/module/ServiceModule';
1010
import { ServiceDefinition } from 'src/Service';
1111
import { Transform, TransformData } from 'src/transform/Transform';
12+
import { getMethods } from 'src/utils';
1213
import { TYPE_JSON, TYPE_TEXT } from 'src/utils/Mime';
1314
import { TemplateScope } from 'src/utils/Template';
1415

16+
import { getHandlerOptions, HandlerOptions } from '.';
17+
18+
export type HandlerMethod = (this: BaseController<ControllerData>, cmd: Command) => Promise<void>;
1519
export type BaseControllerOptions<TData extends ControllerData> = ControllerOptions<TData>;
1620

1721
export enum ErrorReplyType {
@@ -22,6 +26,7 @@ export enum ErrorReplyType {
2226
InvalidVerb = 'invalid-verb',
2327
SessionExists = 'session-exists',
2428
SessionMissing = 'session-missing',
29+
Unknown = 'unknown',
2530
}
2631

2732
@Inject('services')
@@ -69,7 +74,58 @@ export abstract class BaseController<TData extends ControllerData> extends BotSe
6974
return true;
7075
}
7176

72-
public abstract handle(cmd: Command): Promise<void>;
77+
public async handle(cmd: Command): Promise<void> {
78+
this.logger.debug({ cmd }, 'finding handler method for command');
79+
80+
for (const method of getMethods(this)) {
81+
const options = getHandlerOptions(method);
82+
if (isNil(options)) {
83+
continue;
84+
}
85+
86+
this.logger.debug({ cmd, options }, 'checking potential handler method');
87+
if (!this.checkCommand(cmd, options)) {
88+
continue;
89+
}
90+
91+
this.logger.debug({ method: method.name, options }, 'found matching handler method');
92+
return this.invokeHandler(cmd, options, method as HandlerMethod);
93+
}
94+
95+
this.logger.warn({ cmd }, 'no handler method for command');
96+
}
97+
98+
protected checkCommand(cmd: Command, options: HandlerOptions): boolean {
99+
return cmd.noun === options.noun && cmd.verb === options.verb;
100+
}
101+
102+
protected async invokeHandler(cmd: Command, options: HandlerOptions, handler: HandlerMethod): Promise<void> {
103+
if (options.rbac) {
104+
if (options.rbac.user && !cmd.context.user) {
105+
return this.errorReply(cmd.context, ErrorReplyType.SessionMissing);
106+
}
107+
108+
const grants = [];
109+
if (Array.isArray(options.rbac.grants)) {
110+
grants.push(...options.rbac.grants);
111+
}
112+
113+
if (options.rbac.defaultGrant) {
114+
grants.push(`${options.noun}:${options.verb}`);
115+
}
116+
117+
if (!cmd.context.checkGrants(grants)) {
118+
return this.errorReply(cmd.context, ErrorReplyType.GrantMissing);
119+
}
120+
}
121+
122+
try {
123+
return handler.call(this, cmd);
124+
} catch (err) {
125+
this.logger.error(err, 'error during handler method');
126+
return this.errorReply(cmd.context, ErrorReplyType.Unknown, err.message);
127+
}
128+
}
73129

74130
protected async transform(cmd: Command, type: string, body: TemplateScope): Promise<TemplateScope> {
75131
if (this.transforms.length === 0) {
@@ -116,4 +172,20 @@ export abstract class BaseController<TData extends ControllerData> extends BotSe
116172
protected async reply(ctx: Context, body: string): Promise<void> {
117173
await this.bot.sendMessage(Message.reply(ctx, TYPE_TEXT, body));
118174
}
175+
176+
protected getSourceOrFail(ctx: Context) {
177+
const source = ctx.source;
178+
if (isNil(source)) {
179+
throw new MissingValueError('context source must not be nil');
180+
}
181+
return source;
182+
}
183+
184+
protected getUserOrFail(ctx: Context) {
185+
const user = ctx.user;
186+
if (isNil(user)) {
187+
throw new MissingValueError('context user must not be nil');
188+
}
189+
return user;
190+
}
119191
}

0 commit comments

Comments
 (0)